From 19bc88f4d55477d1e17123c8ee9624656bb74966 Mon Sep 17 00:00:00 2001 From: Vladimir Sumarov Date: Tue, 4 Aug 2026 21:38:28 +0200 Subject: [PATCH 1/2] fix: Meta double-paste + cancel on timeout/focus switch Stop Lexical stacking a second collapsed prompt copy after insertText (no insertFromPaste fireInput; stronger duplicate repair). Keep same-origin AI tabs warm instead of re-navigating every hotkey. Cancel paste if the composer is not ready within 10s or the user leaves the AI tab/window (extension + UIA). Hard cancels no longer fall back to a surprise UIA paste on the wrong page. Co-authored-by: Hermes/grok-4.5/m3rcur1al --- extension/background.js | 123 ++++++++++++++-- extension/content.js | 300 ++++++++++++++++++++++++++++++++++------ include/config.hpp | 8 +- include/page_ready.hpp | 4 +- src/app.cpp | 3 + src/config.cpp | 7 + src/page_ready.cpp | 80 +++++++++++ src/workflow.cpp | 133 +++++++++++++++++- 8 files changed, 595 insertions(+), 63 deletions(-) diff --git a/extension/background.js b/extension/background.js index 7c4c5fc..dbc9d41 100644 --- a/extension/background.js +++ b/extension/background.js @@ -88,6 +88,27 @@ function waitTabComplete(tabId, timeoutMs = 25000) { }); } +/** + * True when the existing tab is already on the target AI app. + * Do NOT re-navigate merely because query/hash/conversation id differ — that + * forced cold=1 on every Meta/Gemini hotkey and re-hydrated the composer. + * + * Rules: + * - origin must match + * - want path "/" (meta.ai/, chatgpt.com/): any path on that origin is fine + * - otherwise accept exact path or prefix (gemini /app → /app/xyz) + */ +function isAlreadyOnApp(u, want) { + if (u.origin !== want.origin) return false; + let wantPath = want.pathname || '/'; + if (wantPath.length > 1 && wantPath.endsWith('/')) wantPath = wantPath.slice(0, -1); + if (wantPath === '/' || wantPath === '') return true; + const path = u.pathname || '/'; + if (path === wantPath) return true; + const prefix = wantPath.endsWith('/') ? wantPath : wantPath + '/'; + return path.startsWith(prefix); +} + /** * Focus or open a tab for url. * Returns { tabId, cold } where cold=true if we created a tab or navigated. @@ -110,7 +131,7 @@ async function ensureTab(url) { } } let cold = false; - if (u.pathname !== want.pathname || u.search !== want.search || u.hash !== want.hash) { + if (!isAlreadyOnApp(u, want)) { await chrome.tabs.update(t.id, { url }); cold = true; await waitTabComplete(t.id); @@ -125,9 +146,12 @@ async function ensureTab(url) { return { tabId: created.id, cold: true }; } -async function sendToTab(tabId, message, attempts = 16) { +async function sendToTab(tabId, message, attempts = 16, deadlineMs = 0) { let lastErr = 'no response'; for (let i = 0; i < attempts; ++i) { + if (deadlineMs && Date.now() >= deadlineMs) { + return { ok: false, error: 'timeout waiting for content script' }; + } try { const resp = await chrome.tabs.sendMessage(tabId, message); if (resp) return resp; @@ -145,11 +169,33 @@ async function sendToTab(tabId, message, attempts = 16) { } } } - await sleep(300); + const slice = deadlineMs ? Math.min(300, Math.max(50, deadlineMs - Date.now())) : 300; + if (slice <= 0) break; + await sleep(slice); } return { ok: false, error: lastErr }; } +async function tabStillActiveOnOrigin(tabId, wantOrigin) { + try { + const t = await chrome.tabs.get(tabId); + if (!t) return { ok: false, error: 'tab gone' }; + if (t.active === false) return { ok: false, error: 'tab inactive / focus switched' }; + if (wantOrigin && t.url) { + try { + if (new URL(t.url).origin !== wantOrigin) { + return { ok: false, error: 'tab navigated off AI origin' }; + } + } catch { + /* ignore bad url */ + } + } + return { ok: true }; + } catch (e) { + return { ok: false, error: String(e) }; + } +} + async function onNativeMessage(msg) { log('native msg', msg && msg.cmd, msg && msg.id); if (!msg || typeof msg !== 'object') return; @@ -163,11 +209,19 @@ async function onNativeMessage(msg) { if (msg.cmd === 'prepareAndPaste') { const url = String(msg.url || ''); const text = String(msg.text || ''); - const timeoutMs = Math.max(1000, Number(msg.timeoutMs) || 15000); + // Hard cap 10s for form wait — matches product rule: cancel rather than surprise-paste. + const timeoutMs = Math.min(10000, Math.max(1000, Number(msg.timeoutMs) || 10000)); if (!url) { reply(msg, { ok: false, error: 'missing url' }); return; } + let wantOrigin = ''; + try { + wantOrigin = new URL(url).origin; + } catch { + /* ignore */ + } + const deadline = Date.now() + timeoutMs + 1500; const { tabId, cold } = await ensureTab(url); // Cold open / navigate: SPA shell is not ready at status=complete. if (cold) { @@ -175,12 +229,39 @@ async function onNativeMessage(msg) { } else { await sleep(250); } - const resp = await sendToTab(tabId, { - cmd: 'waitAndPaste', - text, - timeoutMs, - cold: !!cold, - }); + const focusCheck = await tabStillActiveOnOrigin(tabId, wantOrigin); + if (!focusCheck.ok) { + reply(msg, { + ok: false, + error: focusCheck.error || 'tab inactive / focus switched', + cold: !!cold, + }); + return; + } + const remain = Math.max(500, deadline - Date.now()); + const resp = await sendToTab( + tabId, + { + cmd: 'waitAndPaste', + text, + timeoutMs: Math.min(timeoutMs, remain), + cold: !!cold, + wantOrigin, + }, + 16, + deadline, + ); + // Final guard: if user left while content script worked, still report cancel. + const after = await tabStillActiveOnOrigin(tabId, wantOrigin); + if (resp && resp.ok && !after.ok) { + reply(msg, { + ok: false, + error: after.error || 'tab inactive after paste', + detail: resp.detail, + cold: !!cold, + }); + return; + } reply(msg, { ok: !!(resp && resp.ok), detail: (resp && (resp.detail || resp.error)) || '', @@ -193,11 +274,29 @@ async function onNativeMessage(msg) { if (msg.cmd === 'prepare') { const url = String(msg.url || ''); - const timeoutMs = Math.max(1000, Number(msg.timeoutMs) || 15000); + const timeoutMs = Math.min(10000, Math.max(1000, Number(msg.timeoutMs) || 10000)); + let wantOrigin = ''; + try { + wantOrigin = new URL(url).origin; + } catch { + /* ignore */ + } + const deadline = Date.now() + timeoutMs + 1500; const { tabId, cold } = await ensureTab(url); if (cold) await sleep(900); else await sleep(250); - const resp = await sendToTab(tabId, { cmd: 'findComposer', timeoutMs, cold: !!cold }); + const focusCheck = await tabStillActiveOnOrigin(tabId, wantOrigin); + if (!focusCheck.ok) { + reply(msg, { ok: false, error: focusCheck.error || 'tab inactive', cold: !!cold }); + return; + } + const remain = Math.max(500, deadline - Date.now()); + const resp = await sendToTab( + tabId, + { cmd: 'findComposer', timeoutMs: Math.min(timeoutMs, remain), cold: !!cold, wantOrigin }, + 16, + deadline, + ); reply(msg, { ok: !!(resp && resp.ok), detail: (resp && resp.detail) || '', diff --git a/extension/content.js b/extension/content.js index 8a9a69f..c246025 100644 --- a/extension/content.js +++ b/extension/content.js @@ -107,6 +107,7 @@ function boot() { /** * Wait until the same high-scoring composer is observed N times in a row. * Avoids pasting into Gemini/Meta load shells that are replaced on hydrate. + * Resolves { el, cancelReason }. cancelReason set if tab hidden / left / timed out empty. */ function waitForStableComposer(timeoutMs, opts = {}) { const cold = !!opts.cold; @@ -119,6 +120,11 @@ function boot() { return new Promise((resolve) => { const tick = () => { + const abort = pasteAbortReason(opts); + if (abort) { + resolve({ el: null, cancelReason: abort }); + return; + } const el = findComposer(); if (el) { const key = composerKey(el); @@ -132,7 +138,7 @@ function boot() { streak = 1; } if (streak >= needStable) { - resolve(lastEl); + resolve({ el: lastEl, cancelReason: null }); return; } } else { @@ -141,7 +147,10 @@ function boot() { lastEl = null; } if (Date.now() - start >= timeoutMs) { - resolve(lastEl); // may be null + resolve({ + el: lastEl, + cancelReason: lastEl ? null : 'composer not found (timeout)', + }); return; } setTimeout(tick, pollMs); @@ -150,11 +159,32 @@ function boot() { }); } + /** Abort paste if the user left the tab/window or navigated off the AI origin. */ + function pasteAbortReason(opts = {}) { + try { + if (document.hidden || document.visibilityState === 'hidden') { + return 'tab hidden / not focused'; + } + } catch { + /* ignore */ + } + const wantOrigin = opts.wantOrigin ? String(opts.wantOrigin) : ''; + if (wantOrigin) { + try { + if (location.origin !== wantOrigin) { + return 'navigated off AI origin (' + location.origin + ')'; + } + } catch { + /* ignore */ + } + } + return null; + } + function textStillPresent(el, text) { - const compact = (s) => String(s || '').replace(/\s+/g, ''); - const pay = compact(text); + const pay = compactText(text); if (!pay) return true; - const got = compact(sniffText(el)); + const got = compactText(sniffText(el)); if (!got) return false; // Require a meaningful prefix match (full paste may normalize quotes/newlines). const probe = pay.slice(0, Math.min(48, pay.length)); @@ -165,11 +195,15 @@ function boot() { const ok = await pasteInto(el, text); if (!ok) return { ok: false, detail: 'paste failed' }; // SPA hydrate can wipe a successful DOM write a moment later. - await sleep(220); - if (textStillPresent(el, text)) { - return { ok: true, detail: 'paste verified' }; + // Lexical may also apply a delayed second insert — wait long enough to see it. + await sleep(280); + if (!textStillPresent(el, text)) { + return { ok: false, detail: 'paste wiped after hydrate' }; + } + if (looksDuplicated(el, text)) { + return { ok: true, detail: 'paste verified', duplicated: true }; } - return { ok: false, detail: 'paste wiped after hydrate' }; + return { ok: true, detail: 'paste verified', duplicated: false }; } function escapeHtml(s) { @@ -184,13 +218,18 @@ function boot() { return escapeHtml(text).replace(/\r\n|\n|\r/g, '
'); } - function fireInput(el, data) { + /** + * Notify React/controlled hosts of a DOM change. + * NEVER use inputType 'insertFromPaste' here after execCommand('insertText') — + * Lexical (Meta AI) treats that as a second paste and stacks a collapsed copy. + */ + function fireInput(el, data, inputType = 'insertText') { try { el.dispatchEvent( new InputEvent('input', { bubbles: true, cancelable: true, - inputType: 'insertFromPaste', + inputType: inputType || 'insertText', data: data ?? null, }), ); @@ -204,6 +243,46 @@ function boot() { } } + function compactText(s) { + return String(s || '').replace(/\s+/g, ''); + } + + /** How many times `needle` appears non-overlapping in `hay`. */ + function countOccurrences(hay, needle) { + if (!needle || needle.length < 8) return 0; + let n = 0; + let idx = 0; + while ((idx = hay.indexOf(needle, idx)) !== -1) { + n += 1; + idx += needle.length; + } + return n; + } + + function looksDuplicated(el, text) { + const pay = compactText(text); + if (pay.length < 20) return false; + const got = compactText(sniffText(el)); + if (!got) return false; + if (got.includes(pay + pay)) return true; + if (countOccurrences(got, pay) >= 2) return true; + // Collapsed + full copies: length ~2x with payload prefix present twice-ish. + if (got.length >= Math.floor(pay.length * 1.6) && countOccurrences(got, pay.slice(0, 48)) >= 2) { + return true; + } + return false; + } + + function looksSingleGood(el, text) { + const pay = compactText(text); + if (!pay) return true; + const got = compactText(sniffText(el)); + if (!got) return false; + if (looksDuplicated(el, text)) return false; + const probe = pay.slice(0, Math.min(48, pay.length)); + return got.includes(probe) && got.length < Math.floor(pay.length * 1.45) + 32; + } + function setNativeValue(el, text) { const tag = (el.tagName || '').toLowerCase(); if (tag !== 'textarea' && tag !== 'input') return false; @@ -365,6 +444,10 @@ function boot() { * Insert multi-line text exactly once. * Important: Lexical/React often apply paste asynchronously and leave innerText * empty for a tick — do NOT fall through to other insert methods or we stack copies. + * + * Meta AI (Lexical): execCommand('insertText') already updates the editor. + * Firing a follow-up InputEvent(inputType=insertFromPaste) stacks a second, + * often newline-collapsed copy — that is the "prompt twice / half collapsed" bug. */ async function pasteInto(el, text) { const value = String(text ?? ''); @@ -373,7 +456,22 @@ function boot() { // 1) textarea / input — single value assignment. if (setNativeValue(el, value)) return true; - // 2) Clear, then ONE contenteditable strategy. + const isLexical = + el.getAttribute('data-lexical-editor') === 'true' || + !!el.closest('[data-lexical-editor="true"]'); + + // 2) Lexical hosts: synthetic paste alone (their paste handler owns the model). + if (isLexical) { + clearComposer(el); + await sleep(0); + if (dispatchSyntheticPaste(el, value)) { + await sleep(40); + return true; + } + // Fall through to insertText without a second input event. + } + + // 3) Clear, then ONE contenteditable strategy. clearComposer(el); await sleep(0); @@ -383,50 +481,81 @@ function boot() { if (document.queryCommandSupported && !document.queryCommandSupported('insertText')) { /* fall through */ } else if (document.execCommand('insertText', false, value)) { - fireInput(el, value); + // Do not fireInput — insertText already mutated the editing host. + // Extra insertFromPaste events double-insert on Lexical/Meta. return true; } } catch { /* ignore */ } - // 3) Synthetic paste only (no further methods after this succeeds at dispatch). + // 4) Synthetic paste only (no further methods after this succeeds at dispatch). clearComposer(el); await sleep(0); if (dispatchSyntheticPaste(el, value)) { - // Give Lexical a frame to apply; do not chain more inserts. - await sleep(30); + // Give Lexical a frame to apply; do not chain more inserts / fireInput. + await sleep(40); return true; } - // 4) insertHTML with
+ // 5) insertHTML with
clearComposer(el); try { selectAllIn(el); if (document.execCommand('insertHTML', false, plainToHtml(value))) { - fireInput(el, value); + // insertHTML already applied; mild input notify only if not Lexical. + if (!isLexical) fireInput(el, value, 'insertText'); return true; } } catch { /* ignore */ } - // 5) DOM fragment + // 6) DOM fragment clearComposer(el); if (insertMultilineFragment(el, value)) return true; - // 6) last resort + // 7) last resort try { el.innerHTML = plainToHtml(value); - fireInput(el, value); + if (!isLexical) fireInput(el, value, 'insertText'); return true; } catch { return false; } } + /** Clear + single insertText (no fireInput). Used when a duplicate is detected. */ + async function repairToSingle(el, text) { + const value = String(text ?? ''); + clearComposer(el); + await sleep(30); + el.focus(); + selectAllIn(el); + let inserted = false; + try { + inserted = !!document.execCommand('insertText', false, value); + } catch { + inserted = false; + } + if (!inserted) { + clearComposer(el); + await sleep(0); + if (dispatchSyntheticPaste(el, value)) { + await sleep(40); + inserted = true; + } + } + if (!inserted) { + clearComposer(el); + inserted = insertMultilineFragment(el, value); + } + await sleep(120); + return looksSingleGood(el, value); + } + function waitForComposer(timeoutMs) { - return waitForStableComposer(timeoutMs, { cold: false }); + return waitForStableComposer(timeoutMs, { cold: false }).then((r) => r && r.el); } chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { @@ -442,9 +571,19 @@ function boot() { } if (msg.cmd === 'findComposer') { const cold = !!msg.cold; - const el = await waitForStableComposer(Math.max(500, Number(msg.timeoutMs) || 15000), { - cold, - }); + const wantOrigin = msg.wantOrigin ? String(msg.wantOrigin) : ''; + // Cap form wait at 10s unless caller asked for less. + const timeoutMs = Math.min(10000, Math.max(500, Number(msg.timeoutMs) || 10000)); + const waited = await waitForStableComposer(timeoutMs, { cold, wantOrigin }); + if (waited && waited.cancelReason) { + sendResponse({ + ok: false, + error: waited.cancelReason, + href: location.href, + }); + return; + } + const el = waited && waited.el; sendResponse({ ok: !!el, detail: el ? `found ${el.tagName} score-stable` : 'no composer', @@ -453,17 +592,44 @@ function boot() { return; } if (msg.cmd === 'waitAndPaste') { - const timeoutMs = Math.max(500, Number(msg.timeoutMs) || 15000); + // Default/hard cap 10s so a stuck form never pastes later on the wrong page. + const timeoutMs = Math.min(10000, Math.max(500, Number(msg.timeoutMs) || 10000)); const cold = !!msg.cold; + const wantOrigin = msg.wantOrigin ? String(msg.wantOrigin) : ''; const text = String(msg.text ?? ''); const nl = (text.match(/\r\n|\n|\r/g) || []).length; const t0 = Date.now(); + const opts = { cold, wantOrigin }; - let el = await waitForStableComposer(timeoutMs, { cold }); + let waited = await waitForStableComposer(timeoutMs, opts); + if (waited && waited.cancelReason && !waited.el) { + sendResponse({ + ok: false, + error: waited.cancelReason, + href: location.href, + cold, + waitedMs: Date.now() - t0, + }); + return; + } + let el = waited && waited.el; if (!el) { sendResponse({ ok: false, - error: 'composer not found', + error: 'composer not found (timeout)', + href: location.href, + cold, + waitedMs: Date.now() - t0, + }); + return; + } + + // Re-check focus right before mutating the form. + const preAbort = pasteAbortReason(opts); + if (preAbort) { + sendResponse({ + ok: false, + error: preAbort, href: location.href, cold, waitedMs: Date.now() - t0, @@ -474,32 +640,80 @@ function boot() { let result = await pasteWithVerify(el, text); // One retry after wipe/hydrate (common on cold Gemini open). if (!result.ok) { - const remain = Math.max(800, timeoutMs - (Date.now() - t0)); + const remain = Math.max(0, timeoutMs - (Date.now() - t0)); + if (remain < 400) { + sendResponse({ + ok: false, + error: result.detail || 'paste failed (no time to retry)', + href: location.href, + cold, + waitedMs: Date.now() - t0, + }); + return; + } + const midAbort = pasteAbortReason(opts); + if (midAbort) { + sendResponse({ + ok: false, + error: midAbort, + href: location.href, + cold, + waitedMs: Date.now() - t0, + }); + return; + } await sleep(cold ? 500 : 250); - el = await waitForStableComposer(remain, { cold: true }); + waited = await waitForStableComposer(Math.max(400, remain - 500), { + cold: true, + wantOrigin, + }); + if (waited && waited.cancelReason && !waited.el) { + sendResponse({ + ok: false, + error: waited.cancelReason, + href: location.href, + cold, + waitedMs: Date.now() - t0, + }); + return; + } + el = waited && waited.el; if (el) result = await pasteWithVerify(el, text); } - const after = el ? sniffText(el) : ''; - const compact = (s) => String(s).replace(/\s+/g, ''); - const cPay = compact(text); - const cAfter = compact(after); let detail = result.ok ? `pasted ${text.length} chars newlines=${nl} ${result.detail || ''}`.trim() : result.detail || 'paste failed'; - if (result.ok && cPay.length > 20 && cAfter.includes(cPay + cPay)) { + + // Meta/Lexical sometimes still stacks two copies (one collapsed). Detect + repair + // without firing insertFromPaste again (that re-introduces the double). + if (result.ok && el && (result.duplicated || looksDuplicated(el, text))) { detail += ' WARN:duplicate_detected'; - clearComposer(el); - await sleep(0); - try { - selectAllIn(el); - document.execCommand('insertText', false, text); - fireInput(el, text); + const repaired = await repairToSingle(el, text); + if (repaired) { detail += '+repaired'; - } catch { - /* ignore */ + } else { + // Second hard attempt after a short settle. + await sleep(150); + const repaired2 = await repairToSingle(el, text); + detail += repaired2 ? '+repaired' : '+repair_failed'; + if (!repaired2 && looksDuplicated(el, text)) { + // Last resort: clear so user does not send a doubled prompt by accident. + clearComposer(el); + result = { ok: false, detail: 'duplicate paste could not be repaired' }; + detail = result.detail; + } } } + + // Final sniff: reject still-duplicated content even if earlier steps claimed OK. + if (result.ok && el && looksDuplicated(el, text)) { + detail += ' WARN:still_duplicated'; + clearComposer(el); + result = { ok: false, detail: 'paste left duplicated content' }; + detail = result.detail; + } + if (cold) detail += ' cold=1'; detail += ` waitedMs=${Date.now() - t0}`; diff --git a/include/config.hpp b/include/config.hpp index ff47aa9..e8af3af 100644 --- a/include/config.hpp +++ b/include/config.hpp @@ -82,12 +82,16 @@ struct WorkflowConfig int afterFinalPasteMs = 300; int afterImagePasteMs = 350; - int pageReadyTimeoutMs = 15000; + // Hard stop waiting for AI composer / page. After this we cancel (no surprise paste). + int pageReadyTimeoutMs = 10000; int pageReadyPollMs = 150; int pageReadyMinMs = 500; int pageReadySettleMs = 200; bool pageReadyUseUia = true; - bool pasteEvenIfNotReady = true; + // If false (default): timeout / missing form aborts — do not paste into a half-ready page. + bool pasteEvenIfNotReady = false; + // Abort when the user switches away (other app / other tab title) while waiting to paste. + bool cancelOnFocusSwitch = true; bool fenceEditorText = true; // Extra delay after final paste before restoring the user's clipboard. diff --git a/include/page_ready.hpp b/include/page_ready.hpp index 08ef72c..7998794 100644 --- a/include/page_ready.hpp +++ b/include/page_ready.hpp @@ -16,7 +16,7 @@ struct PageReadyConfig // Blank/new-tab titles are rejected in page_ready.cpp::LooksLikeNewTabTitle. std::wstring titleHint; - int timeoutMs = 15000; // hard stop + int timeoutMs = 10000; // hard stop — cancel paste rather than wait forever int pollMs = 150; // poll interval int minWaitMs = 400; // never paste sooner than this after Enter int settleMs = 200; // after ready signal, tiny settle before paste @@ -24,6 +24,8 @@ struct PageReadyConfig bool useUia = true; // UI Automation tree (sees web edits; not real HWNDs) bool preferFocusedEdit = true; bool focusFoundEdit = true; + // If the user leaves the target browser / AI tab while waiting, abort paste. + bool cancelOnFocusSwitch = true; }; struct PageReadyResult diff --git a/src/app.cpp b/src/app.cpp index 6ee7302..462c2db 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -664,6 +664,9 @@ int App::RunSelfTest() expect(!wc.defaultAiUrl.empty(), L"default AI URL set"); expect(!wc.browserTitleHint.empty(), L"browser hint set"); expect(wc.pageReadyTimeoutMs > 0, L"pageReadyTimeoutMs > 0"); + expect(wc.pageReadyTimeoutMs <= 10000, L"default pageReadyTimeoutMs <= 10s"); + expect(wc.pasteEvenIfNotReady == false, L"default pasteEvenIfNotReady off"); + expect(wc.cancelOnFocusSwitch == true, L"default cancelOnFocusSwitch on"); expect(wc.fenceEditorText == true, L"default fenceEditorText on"); { diff --git a/src/config.cpp b/src/config.cpp index 112e8f9..3f78bb4 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -461,6 +461,13 @@ bool LoadConfigFile(const std::wstring& pathOrEmpty, AppConfig& cfg, std::wstrin cfg.workflow.defaultAiUrl = get(L"default_ai_url"); if (!get(L"prefer_extension").empty()) cfg.workflow.preferExtension = get(L"prefer_extension") != L"0"; + if (!get(L"paste_even_if_not_ready").empty()) + cfg.workflow.pasteEvenIfNotReady = get(L"paste_even_if_not_ready") != L"0"; + if (!get(L"cancel_on_focus_switch").empty()) + cfg.workflow.cancelOnFocusSwitch = get(L"cancel_on_focus_switch") != L"0"; + if (!get(L"page_ready_timeout_ms").empty()) + cfg.workflow.pageReadyTimeoutMs = + ClampInt(_wtoi(get(L"page_ready_timeout_ms").c_str()), 500, 120000); if (!get(L"extension_id").empty()) cfg.extensionId = Trim(get(L"extension_id")); if (!get(L"update_url").empty()) diff --git a/src/page_ready.cpp b/src/page_ready.cpp index 6522f5e..a1ed0b4 100644 --- a/src/page_ready.cpp +++ b/src/page_ready.cpp @@ -98,6 +98,63 @@ bool LooksLikeNewTabTitle(const std::wstring& title) return false; } +bool IsAncestorOrSelf(HWND ancestor, HWND hwnd) +{ + if (!ancestor || !hwnd) + return false; + for (HWND w = hwnd; w; w = GetParent(w)) + { + if (w == ancestor) + return true; + } + return false; +} + +// User left the AI target: other app focused, or same browser but title no longer matches hint. +// Returns empty if still OK; otherwise a short cancel reason for logs/errors. +std::wstring FocusSwitchCancelReason(HWND browserHwnd, const std::wstring& titleHint, + bool sawTargetTitle) +{ + if (!browserHwnd || !IsWindow(browserHwnd)) + return L"browser window disappeared"; + + const std::wstring browserTitle = WindowTitleOf(browserHwnd); + // Tab switched away inside the same browser window after we had already seen the AI title. + if (sawTargetTitle && !titleHint.empty() && !LooksLikeNewTabTitle(browserTitle) && + !ContainsI(browserTitle, titleHint)) + { + return L"focus/title switched off AI page (browser title='" + browserTitle + L"')"; + } + + HWND fg = GetForegroundWindow(); + if (!fg) + return {}; + + if (IsAncestorOrSelf(browserHwnd, fg) || fg == browserHwnd) + return {}; + + DWORD fgPid = 0; + DWORD brPid = 0; + GetWindowThreadProcessId(fg, &fgPid); + GetWindowThreadProcessId(browserHwnd, &brPid); + + const std::wstring fgTitle = WindowTitleOf(fg); + + // Different process entirely (Hermes, Slack, game, …). + if (fgPid != 0 && brPid != 0 && fgPid != brPid) + { + return L"focus switched to other window title='" + fgTitle + L"'"; + } + + // Same browser process, other window/tab in foreground without our AI hint. + if (!titleHint.empty() && !ContainsI(fgTitle, titleHint) && !LooksLikeNewTabTitle(fgTitle)) + { + return L"focus switched to other tab/window title='" + fgTitle + L"'"; + } + + return {}; +} + bool IsOmniboxName(const std::wstring& name) { const std::wstring l = ToLowerCopy(name); @@ -455,6 +512,7 @@ bool WaitForAiPageReady(const PageReadyConfig& cfg, PageReadyResult& out, std::w bool titleReady = false; bool editReady = false; + bool sawTargetTitle = false; std::wstring lastTitle; std::wstring prevLoggedTitle; int lastTreeScanMs = -10000; @@ -477,9 +535,31 @@ bool WaitForAiPageReady(const PageReadyConfig& cfg, PageReadyResult& out, std::w return false; } + if (cfg.cancelOnFocusSwitch) + { + const std::wstring focusErr = + FocusSwitchCancelReason(cfg.browserHwnd, cfg.titleHint, sawTargetTitle); + if (!focusErr.empty()) + { + if (automation) + automation->Release(); + out.ready = false; + out.detail = focusErr; + out.title = WindowTitleOf(cfg.browserHwnd); + out.waitedMs = elapsed; + QP_LOG_WARN(L"page_ready: CANCEL %s (t=%dms)", focusErr.c_str(), elapsed); + LogTitleSample(L"page_ready_focus_cancel", cfg.browserHwnd, focusErr); + if (error) + *error = focusErr; + return false; + } + } + lastTitle = WindowTitleOf(cfg.browserHwnd); out.title = lastTitle; titleReady = TitleLooksReady(lastTitle, cfg.titleHint); + if (titleReady) + sawTargetTitle = true; // Log every title *change*, and at least every ~1s while waiting. if (lastTitle != prevLoggedTitle || elapsed - lastTitleLogMs >= 1000) diff --git a/src/workflow.cpp b/src/workflow.cpp index f4bc381..58ec2f7 100644 --- a/src/workflow.cpp +++ b/src/workflow.cpp @@ -41,6 +41,109 @@ bool LooksLikeBrowserClass(const std::wstring& cls) cls == L"MozillaWindowClass"; } +bool ContainsI(const std::wstring& hay, const std::wstring& needle) +{ + if (needle.empty()) + return true; + auto lower = [](std::wstring s) { + for (auto& c : s) + c = static_cast(towlower(c)); + return s; + }; + return lower(hay).find(lower(needle)) != std::wstring::npos; +} + +// Extension/UIA failures that mean "abort entirely" — do not surprise-paste via fallback. +bool IsHardPasteCancel(const std::wstring& msg) +{ + if (msg.empty()) + return false; + static const wchar_t* kKeys[] = { + L"focus switched", + L"focus/title switched", + L"not focused", + L"tab inactive", + L"tab hidden", + L"document hidden", + L"cancelled", + L"canceled", + L"composer not found", + L"timed out", + L"timeout", + L"page not ready", + L"paste left duplicated", + L"could not be repaired", + }; + for (const wchar_t* k : kKeys) + { + if (ContainsI(msg, k)) + return true; + } + return false; +} + +std::wstring WindowTitleHwnd(HWND hwnd) +{ + if (!hwnd || !IsWindow(hwnd)) + return {}; + wchar_t buf[512]{}; + GetWindowTextW(hwnd, buf, 512); + return buf; +} + +// Pre-paste guard for UIA path: FG must still be the AI browser/tab. +bool ForegroundOkForPaste(HWND browserHwnd, const std::wstring& titleHint, std::wstring* why) +{ + if (!browserHwnd || !IsWindow(browserHwnd)) + { + if (why) + *why = L"browser window disappeared before paste"; + return false; + } + HWND fg = GetForegroundWindow(); + if (!fg) + return true; + + auto isUnder = [](HWND ancestor, HWND hwnd) { + for (HWND w = hwnd; w; w = GetParent(w)) + { + if (w == ancestor) + return true; + } + return false; + }; + + const std::wstring brTitle = WindowTitleHwnd(browserHwnd); + if (!titleHint.empty() && !ContainsI(brTitle, titleHint)) + { + // Allow bare browser chrome while loading is rare at paste time — still cancel. + if (why) + *why = L"focus/title switched off AI page (browser title='" + brTitle + L"')"; + return false; + } + + if (fg == browserHwnd || isUnder(browserHwnd, fg)) + return true; + + DWORD fgPid = 0, brPid = 0; + GetWindowThreadProcessId(fg, &fgPid); + GetWindowThreadProcessId(browserHwnd, &brPid); + const std::wstring fgTitle = WindowTitleHwnd(fg); + if (fgPid != 0 && brPid != 0 && fgPid != brPid) + { + if (why) + *why = L"focus switched to other window title='" + fgTitle + L"'"; + return false; + } + if (!titleHint.empty() && !ContainsI(fgTitle, titleHint)) + { + if (why) + *why = L"focus switched to other tab/window title='" + fgTitle + L"'"; + return false; + } + return true; +} + } // namespace AiWorkflow::AiWorkflow(const WorkflowConfig& cfg) : cfg_(cfg) @@ -185,8 +288,9 @@ bool AiWorkflow::Run(const WorkflowRequest& req, std::wstring* error) QP_LOG_INFO(L"workflow: trying Chrome extension prepareAndPaste"); std::wstring detail; std::wstring extErr; + // Cap at page-ready timeout (default 10s). No +5s cushion — long waits → surprise paste. const DWORD extTimeout = - static_cast((std::max)(cfg_.pageReadyTimeoutMs, 5000) + 5000); + static_cast((std::max)(1000, (std::min)(cfg_.pageReadyTimeoutMs, 60000))); if (ExtBridge::Instance().PrepareAndPaste(url, payload, extTimeout, &detail, &extErr)) { QP_LOG_INFO(L"workflow: extension paste OK (%s)", detail.c_str()); @@ -198,8 +302,14 @@ bool AiWorkflow::Run(const WorkflowRequest& req, std::wstring* error) QP_LOG_INFO(L"workflow: DONE (extension)"); return true; } - QP_LOG_WARN(L"workflow: extension path failed (%s) — falling back to UIA", - extErr.empty() ? detail.c_str() : extErr.c_str()); + const std::wstring why = !extErr.empty() ? extErr : detail; + // Timeout / focus-leave / missing form: abort. Do not UIA-paste onto whatever is focused. + if (IsHardPasteCancel(why)) + { + restoreClip(); + return fail(L"extension paste cancelled: " + why); + } + QP_LOG_WARN(L"workflow: extension path failed (%s) — falling back to UIA", why.c_str()); } else if (cfg_.preferExtension) { QP_LOG_DEBUG(L"workflow: extension not connected — UIA path"); @@ -274,15 +384,18 @@ bool AiWorkflow::Run(const WorkflowRequest& req, std::wstring* error) pr.minWaitMs = cfg_.pageReadyMinMs; pr.settleMs = cfg_.pageReadySettleMs; pr.useUia = cfg_.pageReadyUseUia; + pr.cancelOnFocusSwitch = cfg_.cancelOnFocusSwitch; - QP_LOG_INFO(L"workflow: page-ready hint='%s'", pr.titleHint.c_str()); + QP_LOG_INFO(L"workflow: page-ready hint='%s' timeout=%dms cancelOnFocus=%d", + pr.titleHint.c_str(), pr.timeoutMs, pr.cancelOnFocusSwitch ? 1 : 0); PageReadyResult ready{}; std::wstring readyErr; const bool isReady = WaitForAiPageReady(pr, ready, &readyErr); if (!isReady) { QP_LOG_WARN(L"workflow: page not ready (%s)", readyErr.c_str()); - if (!cfg_.pasteEvenIfNotReady) + // Focus-switch / hard cancel always aborts even if pasteEvenIfNotReady is on. + if (!cfg_.pasteEvenIfNotReady || IsHardPasteCancel(readyErr)) { restoreClip(); return fail(readyErr.empty() ? L"page not ready" : readyErr); @@ -298,6 +411,16 @@ bool AiWorkflow::Run(const WorkflowRequest& req, std::wstring* error) ReleaseModifiers(nullptr); WaitModifiersReleased(200); + if (cfg_.cancelOnFocusSwitch) + { + std::wstring focusWhy; + if (!ForegroundOkForPaste(browser.hwnd, pr.titleHint, &focusWhy)) + { + restoreClip(); + return fail(focusWhy.empty() ? L"focus switched before paste" : focusWhy); + } + } + // --- Paste into AI form --- // Clipboard+Ctrl+V keeps newlines/formatting (human-readable). // Meta may re-read the clipboard after Ctrl+V — so we KEEP the payload on From 70e702f598857258fb86776e05596f4bdba72d12 Mon Sep 17 00:00:00 2001 From: Vladimir Sumarov Date: Tue, 4 Aug 2026 22:36:46 +0200 Subject: [PATCH 2/2] fix: address Copilot on paste cancel/repair paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Timeout never returns unstable lastEl - Failed second duplicate repair always fails the paste - Retry budget recomputed after settle (hard 10s cap) - Tab focus check uses chrome.windows.focused (+ windows permission) - cancelOnFocusSwitch passed tray → NM → extension Co-authored-by: Hermes/grok-4.5/m3rcur1al --- extension/background.js | 35 ++++++++++++++++++++----- extension/content.js | 57 +++++++++++++++++++++++++++++------------ extension/manifest.json | 1 + include/ext_bridge.hpp | 4 ++- src/ext_bridge.cpp | 5 ++-- src/workflow.cpp | 3 ++- 6 files changed, 79 insertions(+), 26 deletions(-) diff --git a/extension/background.js b/extension/background.js index dbc9d41..b7810f7 100644 --- a/extension/background.js +++ b/extension/background.js @@ -176,11 +176,10 @@ async function sendToTab(tabId, message, attempts = 16, deadlineMs = 0) { return { ok: false, error: lastErr }; } -async function tabStillActiveOnOrigin(tabId, wantOrigin) { +async function tabStillActiveOnOrigin(tabId, wantOrigin, cancelOnFocusSwitch = true) { try { const t = await chrome.tabs.get(tabId); if (!t) return { ok: false, error: 'tab gone' }; - if (t.active === false) return { ok: false, error: 'tab inactive / focus switched' }; if (wantOrigin && t.url) { try { if (new URL(t.url).origin !== wantOrigin) { @@ -190,6 +189,20 @@ async function tabStillActiveOnOrigin(tabId, wantOrigin) { /* ignore bad url */ } } + if (!cancelOnFocusSwitch) return { ok: true }; + + // Tab.active only means selected in its window — still true if another app has OS focus. + if (t.active === false) return { ok: false, error: 'tab inactive / focus switched' }; + if (t.windowId != null) { + try { + const w = await chrome.windows.get(t.windowId); + if (w && w.focused === false) { + return { ok: false, error: 'browser window not focused' }; + } + } catch { + /* ignore — some hosts deny windows.get */ + } + } return { ok: true }; } catch (e) { return { ok: false, error: String(e) }; @@ -211,6 +224,8 @@ async function onNativeMessage(msg) { const text = String(msg.text || ''); // Hard cap 10s for form wait — matches product rule: cancel rather than surprise-paste. const timeoutMs = Math.min(10000, Math.max(1000, Number(msg.timeoutMs) || 10000)); + // Default true; tray can pass cancelOnFocusSwitch:false to disable focus leave cancel. + const cancelOnFocusSwitch = msg.cancelOnFocusSwitch !== false; if (!url) { reply(msg, { ok: false, error: 'missing url' }); return; @@ -229,7 +244,7 @@ async function onNativeMessage(msg) { } else { await sleep(250); } - const focusCheck = await tabStillActiveOnOrigin(tabId, wantOrigin); + const focusCheck = await tabStillActiveOnOrigin(tabId, wantOrigin, cancelOnFocusSwitch); if (!focusCheck.ok) { reply(msg, { ok: false, @@ -247,12 +262,13 @@ async function onNativeMessage(msg) { timeoutMs: Math.min(timeoutMs, remain), cold: !!cold, wantOrigin, + cancelOnFocusSwitch, }, 16, deadline, ); // Final guard: if user left while content script worked, still report cancel. - const after = await tabStillActiveOnOrigin(tabId, wantOrigin); + const after = await tabStillActiveOnOrigin(tabId, wantOrigin, cancelOnFocusSwitch); if (resp && resp.ok && !after.ok) { reply(msg, { ok: false, @@ -275,6 +291,7 @@ async function onNativeMessage(msg) { if (msg.cmd === 'prepare') { const url = String(msg.url || ''); const timeoutMs = Math.min(10000, Math.max(1000, Number(msg.timeoutMs) || 10000)); + const cancelOnFocusSwitch = msg.cancelOnFocusSwitch !== false; let wantOrigin = ''; try { wantOrigin = new URL(url).origin; @@ -285,7 +302,7 @@ async function onNativeMessage(msg) { const { tabId, cold } = await ensureTab(url); if (cold) await sleep(900); else await sleep(250); - const focusCheck = await tabStillActiveOnOrigin(tabId, wantOrigin); + const focusCheck = await tabStillActiveOnOrigin(tabId, wantOrigin, cancelOnFocusSwitch); if (!focusCheck.ok) { reply(msg, { ok: false, error: focusCheck.error || 'tab inactive', cold: !!cold }); return; @@ -293,7 +310,13 @@ async function onNativeMessage(msg) { const remain = Math.max(500, deadline - Date.now()); const resp = await sendToTab( tabId, - { cmd: 'findComposer', timeoutMs: Math.min(timeoutMs, remain), cold: !!cold, wantOrigin }, + { + cmd: 'findComposer', + timeoutMs: Math.min(timeoutMs, remain), + cold: !!cold, + wantOrigin, + cancelOnFocusSwitch, + }, 16, deadline, ); diff --git a/extension/content.js b/extension/content.js index c246025..202b473 100644 --- a/extension/content.js +++ b/extension/content.js @@ -147,9 +147,10 @@ function boot() { lastEl = null; } if (Date.now() - start >= timeoutMs) { + // Never hand back an unstable lastEl on timeout — that defeats stability + cancel. resolve({ - el: lastEl, - cancelReason: lastEl ? null : 'composer not found (timeout)', + el: null, + cancelReason: 'composer not found (timeout)', }); return; } @@ -161,12 +162,16 @@ function boot() { /** Abort paste if the user left the tab/window or navigated off the AI origin. */ function pasteAbortReason(opts = {}) { - try { - if (document.hidden || document.visibilityState === 'hidden') { - return 'tab hidden / not focused'; + // When cancelOnFocusSwitch is explicitly false, skip hide/focus abort (timeout still applies). + const cancelFocus = opts.cancelOnFocusSwitch !== false; + if (cancelFocus) { + try { + if (document.hidden || document.visibilityState === 'hidden') { + return 'tab hidden / not focused'; + } + } catch { + /* ignore */ } - } catch { - /* ignore */ } const wantOrigin = opts.wantOrigin ? String(opts.wantOrigin) : ''; if (wantOrigin) { @@ -572,9 +577,14 @@ function boot() { if (msg.cmd === 'findComposer') { const cold = !!msg.cold; const wantOrigin = msg.wantOrigin ? String(msg.wantOrigin) : ''; + const cancelOnFocusSwitch = msg.cancelOnFocusSwitch !== false; // Cap form wait at 10s unless caller asked for less. const timeoutMs = Math.min(10000, Math.max(500, Number(msg.timeoutMs) || 10000)); - const waited = await waitForStableComposer(timeoutMs, { cold, wantOrigin }); + const waited = await waitForStableComposer(timeoutMs, { + cold, + wantOrigin, + cancelOnFocusSwitch, + }); if (waited && waited.cancelReason) { sendResponse({ ok: false, @@ -596,13 +606,14 @@ function boot() { const timeoutMs = Math.min(10000, Math.max(500, Number(msg.timeoutMs) || 10000)); const cold = !!msg.cold; const wantOrigin = msg.wantOrigin ? String(msg.wantOrigin) : ''; + const cancelOnFocusSwitch = msg.cancelOnFocusSwitch !== false; const text = String(msg.text ?? ''); const nl = (text.match(/\r\n|\n|\r/g) || []).length; const t0 = Date.now(); - const opts = { cold, wantOrigin }; + const opts = { cold, wantOrigin, cancelOnFocusSwitch }; let waited = await waitForStableComposer(timeoutMs, opts); - if (waited && waited.cancelReason && !waited.el) { + if (waited && waited.cancelReason) { sendResponse({ ok: false, error: waited.cancelReason, @@ -640,7 +651,7 @@ function boot() { let result = await pasteWithVerify(el, text); // One retry after wipe/hydrate (common on cold Gemini open). if (!result.ok) { - const remain = Math.max(0, timeoutMs - (Date.now() - t0)); + let remain = Math.max(0, timeoutMs - (Date.now() - t0)); if (remain < 400) { sendResponse({ ok: false, @@ -662,12 +673,26 @@ function boot() { }); return; } - await sleep(cold ? 500 : 250); - waited = await waitForStableComposer(Math.max(400, remain - 500), { + const settle = Math.min(cold ? 500 : 250, remain - 100); + if (settle > 0) await sleep(settle); + // Recompute budget after settle — never exceed the hard timeout. + remain = Math.max(0, timeoutMs - (Date.now() - t0)); + if (remain < 200) { + sendResponse({ + ok: false, + error: result.detail || 'paste failed (timeout after settle)', + href: location.href, + cold, + waitedMs: Date.now() - t0, + }); + return; + } + waited = await waitForStableComposer(remain, { cold: true, wantOrigin, + cancelOnFocusSwitch, }); - if (waited && waited.cancelReason && !waited.el) { + if (waited && waited.cancelReason) { sendResponse({ ok: false, error: waited.cancelReason, @@ -697,8 +722,8 @@ function boot() { await sleep(150); const repaired2 = await repairToSingle(el, text); detail += repaired2 ? '+repaired' : '+repair_failed'; - if (!repaired2 && looksDuplicated(el, text)) { - // Last resort: clear so user does not send a doubled prompt by accident. + // Any failed second repair must fail the op (blank/truncated is not OK either). + if (!repaired2) { clearComposer(el); result = { ok: false, detail: 'duplicate paste could not be repaired' }; detail = result.detail; diff --git a/extension/manifest.json b/extension/manifest.json index 76f0db7..4befdba 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -7,6 +7,7 @@ "permissions": [ "nativeMessaging", "tabs", + "windows", "scripting" ], "host_permissions": [ diff --git a/include/ext_bridge.hpp b/include/ext_bridge.hpp index ccf4c51..f2463d7 100644 --- a/include/ext_bridge.hpp +++ b/include/ext_bridge.hpp @@ -34,8 +34,10 @@ class ExtBridge std::wstring* error = nullptr); // Convenience: prepare tab + paste text via extension. + // cancelOnFocusSwitch: when true (default), extension aborts if tab/window loses focus. bool PrepareAndPaste(const std::wstring& url, const std::wstring& text, DWORD timeoutMs, - std::wstring* detail, std::wstring* error = nullptr); + std::wstring* detail, std::wstring* error = nullptr, + bool cancelOnFocusSwitch = true); bool Ping(DWORD timeoutMs = 1500); diff --git a/src/ext_bridge.cpp b/src/ext_bridge.cpp index 018ee77..3dc6ba2 100644 --- a/src/ext_bridge.cpp +++ b/src/ext_bridge.cpp @@ -590,13 +590,14 @@ bool ExtBridge::Ping(DWORD timeoutMs) } bool ExtBridge::PrepareAndPaste(const std::wstring& url, const std::wstring& text, DWORD timeoutMs, - std::wstring* detail, std::wstring* error) + std::wstring* detail, std::wstring* error, bool cancelOnFocusSwitch) { const std::string urlU = WideToUtf8(url); const std::string textU = WideToUtf8(text); std::ostringstream oss; oss << "{\"cmd\":\"prepareAndPaste\",\"url\":\"" << JsonEscape(urlU) << "\",\"text\":\"" - << JsonEscape(textU) << "\",\"timeoutMs\":" << static_cast(timeoutMs) << "}"; + << JsonEscape(textU) << "\",\"timeoutMs\":" << static_cast(timeoutMs) + << ",\"cancelOnFocusSwitch\":" << (cancelOnFocusSwitch ? "true" : "false") << "}"; std::string resp; if (!Call(oss.str(), resp, timeoutMs + 2000, error)) diff --git a/src/workflow.cpp b/src/workflow.cpp index 58ec2f7..1867f5a 100644 --- a/src/workflow.cpp +++ b/src/workflow.cpp @@ -291,7 +291,8 @@ bool AiWorkflow::Run(const WorkflowRequest& req, std::wstring* error) // Cap at page-ready timeout (default 10s). No +5s cushion — long waits → surprise paste. const DWORD extTimeout = static_cast((std::max)(1000, (std::min)(cfg_.pageReadyTimeoutMs, 60000))); - if (ExtBridge::Instance().PrepareAndPaste(url, payload, extTimeout, &detail, &extErr)) + if (ExtBridge::Instance().PrepareAndPaste(url, payload, extTimeout, &detail, &extErr, + cfg_.cancelOnFocusSwitch)) { QP_LOG_INFO(L"workflow: extension paste OK (%s)", detail.c_str()); // Extension set the composer via DOM — no clipboard hold required for SPA race.