diff --git a/extension/background.js b/extension/background.js
index 7c4c5fc..b7810f7 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,46 @@ 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, cancelOnFocusSwitch = true) {
+ try {
+ const t = await chrome.tabs.get(tabId);
+ if (!t) return { ok: false, error: 'tab gone' };
+ 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 */
+ }
+ }
+ 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) };
+ }
+}
+
async function onNativeMessage(msg) {
log('native msg', msg && msg.cmd, msg && msg.id);
if (!msg || typeof msg !== 'object') return;
@@ -163,11 +222,21 @@ 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));
+ // 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;
}
+ 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 +244,40 @@ 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, cancelOnFocusSwitch);
+ 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,
+ cancelOnFocusSwitch,
+ },
+ 16,
+ deadline,
+ );
+ // Final guard: if user left while content script worked, still report cancel.
+ const after = await tabStillActiveOnOrigin(tabId, wantOrigin, cancelOnFocusSwitch);
+ 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 +290,36 @@ 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));
+ const cancelOnFocusSwitch = msg.cancelOnFocusSwitch !== false;
+ 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, cancelOnFocusSwitch);
+ 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,
+ cancelOnFocusSwitch,
+ },
+ 16,
+ deadline,
+ );
reply(msg, {
ok: !!(resp && resp.ok),
detail: (resp && resp.detail) || '',
diff --git a/extension/content.js b/extension/content.js
index 8a9a69f..202b473 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,11 @@ function boot() {
lastEl = null;
}
if (Date.now() - start >= timeoutMs) {
- resolve(lastEl); // may be null
+ // Never hand back an unstable lastEl on timeout — that defeats stability + cancel.
+ resolve({
+ el: null,
+ cancelReason: 'composer not found (timeout)',
+ });
return;
}
setTimeout(tick, pollMs);
@@ -150,11 +160,36 @@ function boot() {
});
}
+ /** Abort paste if the user left the tab/window or navigated off the AI origin. */
+ function pasteAbortReason(opts = {}) {
+ // 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 */
+ }
+ }
+ 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 +200,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 +223,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 +248,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 +449,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 +461,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 +486,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 +576,24 @@ function boot() {
}
if (msg.cmd === 'findComposer') {
const cold = !!msg.cold;
- const el = await waitForStableComposer(Math.max(500, Number(msg.timeoutMs) || 15000), {
+ 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,
+ cancelOnFocusSwitch,
});
+ 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 +602,45 @@ 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 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, cancelOnFocusSwitch };
- let el = await waitForStableComposer(timeoutMs, { cold });
+ let waited = await waitForStableComposer(timeoutMs, opts);
+ if (waited && waited.cancelReason) {
+ 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 +651,94 @@ 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));
- await sleep(cold ? 500 : 250);
- el = await waitForStableComposer(remain, { cold: true });
+ let 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;
+ }
+ 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) {
+ 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';
+ // 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;
+ }
}
}
+
+ // 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/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/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/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/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/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/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..1867f5a 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,9 +288,11 @@ 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);
- if (ExtBridge::Instance().PrepareAndPaste(url, payload, extTimeout, &detail, &extErr))
+ static_cast((std::max)(1000, (std::min)(cfg_.pageReadyTimeoutMs, 60000)));
+ 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.
@@ -198,8 +303,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 +385,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 +412,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