From 7a441aee4839ba7633a24fc352633bcce35aa469 Mon Sep 17 00:00:00 2001 From: rob Date: Wed, 15 Oct 2025 11:20:01 -0300 Subject: [PATCH] fixed all warnings and the docs --- src/ai-repo-commander.user.js | 305 +++++++++++++++++++++++++++++----- 1 file changed, 268 insertions(+), 37 deletions(-) diff --git a/src/ai-repo-commander.user.js b/src/ai-repo-commander.user.js index 4f54af0..af99a04 100644 --- a/src/ai-repo-commander.user.js +++ b/src/ai-repo-commander.user.js @@ -63,8 +63,10 @@ // Paste + submit behavior APPEND_TRAILING_NEWLINE: true, AUTO_SUBMIT: true, - POST_PASTE_DELAY_MS: 250, + POST_PASTE_DELAY_MS: 600, SUBMIT_MODE: 'button_first', + MAX_COMPOSER_WAIT_MS: 15 * 60 * 1000, // 15 minutes + SUBMIT_MAX_RETRIES: 12, // Streaming-complete hardening // SETTLE_CHECK_MS is the "stable window" after last text change; @@ -81,12 +83,14 @@ SCAN_DEBOUNCE_MS: 400, FAST_WARN_MS: 50, SLOW_WARN_MS: 60_000, + CLUSTER_RESCAN_MS: 1000, // time window to rescan adjacent messages + CLUSTER_MAX_LOOKAHEAD: 3, // how many adjacent assistant messages to check // Queue management - QUEUE_MIN_DELAY_MS: 800, + QUEUE_MIN_DELAY_MS: 1500, QUEUE_MAX_PER_MINUTE: 15, QUEUE_MAX_PER_MESSAGE: 5, - QUEUE_WAIT_FOR_COMPOSER_MS: 6000, + QUEUE_WAIT_FOR_COMPOSER_MS: 12000, RESPONSE_BUFFER_FLUSH_DELAY_MS: 500, // wait for siblings to finish RESPONSE_BUFFER_SECTION_HEADINGS: true, @@ -112,6 +116,11 @@ const CONFIG = loadSavedConfig(); + // Ensure response buffer singleton exists before command execution + if (!window.AI_REPO_RESPONSES) { + window.AI_REPO_RESPONSES = new ResponseBuffer(); + } + // ---------------------- Debug Console ---------------------- let RC_DEBUG = null; @@ -826,6 +835,90 @@ return hits; } + // Chainable actions that may trigger cluster rescan + const chainableActions = ['create_repo', 'create_file', 'create_branch', 'update_file', 'delete_file', 'create_pr']; + + // 1) Check if we should trigger a cluster rescan after executing an action + function shouldTriggerClusterRescan(anchorEl, justExecutedAction) { + if (!chainableActions.includes(justExecutedAction)) return false; + + // Check if next sibling is an unprocessed assistant message + let nextSibling = anchorEl?.nextElementSibling; + while (nextSibling) { + // Stop at user messages + if (commandMonitor && !commandMonitor.isAssistantMessage(nextSibling)) return false; + + // Check if it's an assistant message + if (commandMonitor && commandMonitor.isAssistantMessage(nextSibling)) { + // Check if unprocessed (no processed marker) + const hasMarker = nextSibling?.dataset?.aiRcProcessed === '1' || !!nextSibling.querySelector('.ai-rc-queue-badge'); + return !hasMarker; + } + + nextSibling = nextSibling.nextElementSibling; + } + return false; + } + + // 2) Schedule a cluster rescan to check adjacent assistant messages + async function scheduleClusterRescan(anchorEl) { + if (!anchorEl) return; + + RC_DEBUG?.info('Scheduling cluster rescan', { anchor: anchorEl }); + + const deadline = Date.now() + CONFIG.CLUSTER_RESCAN_MS; + let scanned = 0; + let currentEl = anchorEl.nextElementSibling; + + while (currentEl && scanned < CONFIG.CLUSTER_MAX_LOOKAHEAD && Date.now() < deadline) { + // Stop at user message boundaries + if (commandMonitor && !commandMonitor.isAssistantMessage(currentEl)) { + RC_DEBUG?.verbose('Cluster rescan hit user message boundary'); + break; + } + + // Only process assistant messages + if (commandMonitor && commandMonitor.isAssistantMessage(currentEl)) { + // Check if already processed + const hasMarker = currentEl?.dataset?.aiRcProcessed === '1' || !!currentEl.querySelector('.ai-rc-queue-badge'); + if (!hasMarker) { + // Look for new @bridge@ blocks + const hits = findAllCommandsInMessage(currentEl); + if (hits.length > 0) { + RC_DEBUG?.info('Cluster rescan found commands in adjacent message', { count: hits.length }); + + // 1) Set dataset marker + currentEl.dataset.aiRcProcessed = '1'; + + // 2) Slice hits to CONFIG.QUEUE_MAX_PER_MESSAGE + const capped = hits.slice(0, CONFIG.QUEUE_MAX_PER_MESSAGE); + + // 3) Mark and enqueue each command + capped.forEach((h, idx) => { + if (commandMonitor) { + commandMonitor.history.markElement(currentEl, idx + 1); + commandMonitor.enqueueCommand(currentEl, h, idx); + } + }); + + // 4) Add queue badge with capped count + attachQueueBadge(currentEl, capped.length); + } + } + scanned++; + } + + currentEl = currentEl.nextElementSibling; + + // Small delay between checks + if (currentEl && Date.now() < deadline) { + await ExecutionManager.delay(100); + } + } + + RC_DEBUG?.verbose('Cluster rescan completed', { scanned, deadline: Date.now() >= deadline }); + } + // Tiny badge on the message showing how many got queued function attachQueueBadge(el, count) { if (el.querySelector('.ai-rc-queue-badge')) return; @@ -851,10 +944,28 @@ } const el = getVisibleInputCandidate(); const btn = findSendButton(el); - const btnReady = !btn || (!btn.disabled && btn.getAttribute('aria-disabled') !== 'true'); + const btnReady = CONFIG.SUBMIT_MODE === 'enter_only' + ? true + : (!!btn && !btn.disabled && btn.getAttribute('aria-disabled') !== 'true'); const scope = el?.closest('form, [data-testid="composer"], main, body') || document; - const busy = scope.querySelector('[aria-busy="true"], [data-state="loading"]'); - if (el && btnReady && !busy) return true; + + // 1) Add typing indicator to busy selector + const busy = scope.querySelector('[aria-busy="true"], [data-state="loading"], .typing-indicator'); + + // 2) Check if composer has unsent content + let hasUnsent = false; + if (el) { + try { + const currentText = (el.textContent || el.value || '').trim(); + if (currentText.startsWith('@bridge@') || currentText.startsWith('### [')) { + hasUnsent = true; + } + } catch (e) { + RC_DEBUG?.verbose('Failed to check composer content', { error: String(e) }); + } + } + + if (el && btnReady && !busy && !hasUnsent) return true; await ExecutionManager.delay(pollMs); } RC_DEBUG?.warn('Composer not ready within timeout'); @@ -1329,10 +1440,19 @@ async function submitComposer() { try { - const btn = findSendButton(); - if (CONFIG.SUBMIT_MODE !== 'enter_only' && btn) { btn.click(); return true; } + // 1) Get composer element first const el = getVisibleInputCandidate(); if (!el) return false; + + // 2) Find send button scoped to composer + const btn = findSendButton(el); + + // 3) Check SUBMIT_MODE and click or press Enter + if (CONFIG.SUBMIT_MODE !== 'enter_only' && btn) { + btn.click(); + return true; + } + return pressEnterOn(el); } catch { return false; @@ -1384,12 +1504,11 @@ // Pad with blank lines before/after to preserve ``` fences visually. const payload2 = `\n${payload.replace(/\n?$/, '\n')}\n`; - const escape = (s) => s.replace(/&/g,'&').replace(//g,'>'); - - el.innerHTML = String(payload2) - .split('\n') - .map(line => line.length ? `

${escape(line)}

` : '


') - .join(''); + + // Use text node to preserve code fences better + const textNode = document.createTextNode(payload2); + el.innerHTML = ''; + el.appendChild(textNode); el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); RC_DEBUG?.info('✅ Paste method succeeded: ProseMirror'); @@ -1478,34 +1597,90 @@ } } - async function pasteAndMaybeSubmit(text) { - const ready = await waitForComposerReady({ timeoutMs: CONFIG.QUEUE_WAIT_FOR_COMPOSER_MS }); - if (!ready) { - RC_DEBUG?.warn('Composer not ready; re-queueing paste'); - execQueue.push(async () => { await pasteAndMaybeSubmit(text); }); + async function pasteAndMaybeSubmit(text, attempt = 0, startedAt = Date.now(), submitRetry = 0) { + // 1) Check if elapsed time exceeds MAX_COMPOSER_WAIT_MS + const elapsed = Date.now() - startedAt; + if (elapsed > CONFIG.MAX_COMPOSER_WAIT_MS) { + RC_DEBUG?.error('pasteAndMaybeSubmit gave up after max wait time', { + elapsed, + maxWait: CONFIG.MAX_COMPOSER_WAIT_MS, + attempt, + submitRetry + }); + GM_notification({ + title: 'AI Repo Commander', + text: `Paste/submit failed: composer not ready after ${Math.floor(elapsed / 1000)}s`, + timeout: 6000 + }); return false; } - const pasted = pasteToComposer(text); - if (!pasted) return false; - - try { - const el = getVisibleInputCandidate(); - const actualContent = el?.textContent || el?.value || '[no content found]'; - RC_DEBUG?.info('📋 Content in composer after paste', { - expectedLength: text.length, - actualLength: actualContent.length, - actualPreview: actualContent.substring(0, 200) + // 2) Quick readiness probe with 1200ms timeout + const ready = await waitForComposerReady({ timeoutMs: 1200 }); + if (!ready) { + // 3) Not ready, requeue with exponential backoff (600ms base, cap at 30s) + const backoffMs = Math.min(30_000, Math.floor(600 * Math.pow(1.6, attempt))); + RC_DEBUG?.warn('Composer not ready; re-queueing paste with backoff', { + attempt, + backoffMs, + elapsed }); - } catch (e) { - RC_DEBUG?.warn('Could not read composer content', { error: String(e) }); + setTimeout(() => { + execQueue.push(async () => { + await pasteAndMaybeSubmit(text, attempt + 1, startedAt, submitRetry); + }); + }, backoffMs); + return false; + } + + // 4) Only paste if text is non-empty (enables submit-only retries) + if (text && text.length > 0) { + const pasted = pasteToComposer(text); + if (!pasted) return false; + + try { + const el = getVisibleInputCandidate(); + const actualContent = el?.textContent || el?.value || '[no content found]'; + RC_DEBUG?.info('📋 Content in composer after paste', { + expectedLength: text.length, + actualLength: actualContent.length, + actualPreview: actualContent.substring(0, 200) + }); + } catch (e) { + RC_DEBUG?.warn('Could not read composer content', { error: String(e) }); + } } if (!CONFIG.AUTO_SUBMIT) return true; + + // 5) After paste, wait POST_PASTE_DELAY_MS await ExecutionManager.delay(CONFIG.POST_PASTE_DELAY_MS); + + // 6) Try submitComposer() const ok = await submitComposer(); if (!ok) { - GM_notification({ title: 'AI Repo Commander', text: 'Pasted content, but auto-submit did not trigger.', timeout: 4000 }); + // 7) If submit fails, and we haven't hit SUBMIT_MAX_RETRIES, requeue submit-only retry + if (submitRetry < CONFIG.SUBMIT_MAX_RETRIES) { + const submitBackoffMs = Math.min(30_000, Math.floor(500 * Math.pow(1.6, submitRetry))); + RC_DEBUG?.warn('Submit failed; re-queueing submit-only retry with backoff', { + submitRetry, + submitBackoffMs + }); + setTimeout(() => { + execQueue.push(async () => { + // Empty text for submit-only retry, increment submitRetry + await pasteAndMaybeSubmit('', attempt, startedAt, submitRetry + 1); + }); + }, submitBackoffMs); + return false; + } else { + RC_DEBUG?.error('Submit failed after max retries', { submitRetry, maxRetries: CONFIG.SUBMIT_MAX_RETRIES }); + GM_notification({ + title: 'AI Repo Commander', + text: `Pasted content, but auto-submit failed after ${CONFIG.SUBMIT_MAX_RETRIES} retries.`, + timeout: 6000 + }); + } } return true; } @@ -1743,7 +1918,7 @@ if (command.action === 'get_file') { const body = this._extractGetFileBody(data); if (typeof body === 'string' && body.length) { - new ResponseBuffer().push({ label, content: body }); + (window.AI_REPO_RESPONSES || new ResponseBuffer()).push({ label, content: body }); } else { GM_notification({ title: 'AI Repo Commander', text: 'get_file succeeded, but no content to paste.', timeout: 4000 }); } @@ -1753,10 +1928,10 @@ const files = this._extractFilesArray(data); if (files && files.length) { const listing = this._formatFilesListing(files); - new ResponseBuffer().push({ label, content: listing }); + (window.AI_REPO_RESPONSES || new ResponseBuffer()).push({ label, content: listing }); } else { const fallback = '```json\n' + JSON.stringify(data, null, 2) + '\n```'; - new ResponseBuffer().push({ label, content: fallback }); + (window.AI_REPO_RESPONSES || new ResponseBuffer()).push({ label, content: fallback }); GM_notification({ title: 'AI Repo Commander', text: 'list_files succeeded, but response had no obvious files array. Pasted raw JSON.', @@ -1765,6 +1940,15 @@ } } + // Trigger cluster rescan for chainable commands + try { + if (shouldTriggerClusterRescan(sourceElement, command.action)) { + await scheduleClusterRescan(sourceElement); + } + } catch (e) { + RC_DEBUG?.verbose('Cluster rescan failed', { error: String(e) }); + } + return { success: true, data, isMock }; } @@ -2070,6 +2254,7 @@ // MutationObserver for immediate detection - watching edits AND additions this.observer = new MutationObserver((mutations) => { let shouldScan = false; + let adjacentToProcessed = false; let reasons = new Set(); for (const m of mutations) { @@ -2115,7 +2300,33 @@ if (shouldScan) break; } - if (shouldScan) { + // D) Check for new assistant messages adjacent to already-processed ones (split messages) + if (!adjacentToProcessed) { + for (const m of mutations) { + if (m.type === 'childList') { + for (const node of m.addedNodes) { + if (node.nodeType !== 1) continue; + + // Check if it's an assistant message + const isAssistantMsg = node.matches?.(this.currentPlatform.messages) && + this.isAssistantMessage(node); + + if (isAssistantMsg) { + // Check if previous sibling is a processed assistant message + const prev = node.previousElementSibling; + if (prev && prev.dataset?.aiRcProcessed === '1' && this.isAssistantMessage(prev)) { + reasons.add('split message detected'); + adjacentToProcessed = true; + break; + } + } + } + if (adjacentToProcessed) break; + } + } + } + + if (shouldScan || adjacentToProcessed) { RC_DEBUG?.trace('MO: scan triggered', { reasons: Array.from(reasons).join(', ') }); scheduleScan(); } @@ -2256,7 +2467,27 @@ const subId = `${messageId}#${idx + 1}`; execQueue.push(async () => { - const finalTxt = hit.text; // <<< ADD THIS + // Micro-settle: wait for text to stabilize before parsing + try { + const blockElement = hit.blockElement; + if (blockElement) { + let lastText = blockElement.textContent || ''; + const maxWait = 400; + const checkInterval = 80; + const startTime = Date.now(); + + while (Date.now() - startTime < maxWait) { + await new Promise(r => setTimeout(r, checkInterval)); + const currentText = blockElement.textContent || ''; + if (currentText === lastText) break; // Text stabilized + lastText = currentText; + } + } + } catch (e) { + RC_DEBUG?.verbose('Micro-settle failed, continuing anyway', { error: String(e) }); + } + + const finalTxt = hit.text; let parsed; try { parsed = CommandParser.parseYAMLCommand(finalTxt);