diff --git a/background.js b/background.js index cb07b0f..fc0feb7 100644 --- a/background.js +++ b/background.js @@ -2485,76 +2485,186 @@ async function sendPromptToSpecificTab(tabId, text) { }; } - const inputMatch = findMatch('composer', isComposerCandidate); - const input = inputMatch.element; + function getComposerText(element) { + if (!element) return ''; - if (!input) { - return compatibilityFailure('composer', 'required composer signal was not found.', { - activeElement: describeElement(document.activeElement) - }); + const tagName = (element.tagName || '').toLowerCase(); + if (tagName === 'textarea') { + return String(element.value || ''); + } + + const directText = element.innerText || element.textContent || ''; + if (directText) return String(directText); + + // Lightweight test DOMs do not always maintain a parent's + // textContent after appendChild. Reading child text here + // also matches the browser's contenteditable text. + return Array.from(element.children || []) + .map(child => child.innerText || child.textContent || '') + .join(''); } - input.focus(); - const tagName = (input.tagName || '').toLowerCase(); - if (tagName === 'textarea') { - input.value = String(msg || ''); - } else { - input.textContent = ''; - if (input.classList && typeof input.classList.remove === 'function') { - input.classList.remove('ql-blank'); - } - const paragraph = document.createElement('p'); - paragraph.innerText = msg; - input.appendChild(paragraph); + function normalizeComposerText(value) { + return String(value || '').replace(/\u200b/g, '').trim(); } - if (typeof InputEvent === 'function') { - input.dispatchEvent(new InputEvent('input', { - bubbles: true, - inputType: 'insertText', - data: msg - })); + function setComposerText(element, value) { + const nextText = String(value || ''); + const tagName = (element.tagName || '').toLowerCase(); + element.focus(); + + if (tagName === 'textarea') { + element.value = nextText; + } else { + if (typeof element.replaceChildren === 'function') { + element.replaceChildren(); + } else if (Array.isArray(element.children)) { + element.children.length = 0; + } + element.innerText = ''; + element.textContent = ''; + if (element.classList && typeof element.classList.remove === 'function') { + element.classList.remove('ql-blank'); + } + if (nextText) { + const paragraph = document.createElement('p'); + paragraph.innerText = nextText; + paragraph.textContent = nextText; + element.appendChild(paragraph); + } + } + + if (typeof InputEvent === 'function') { + element.dispatchEvent(new InputEvent('input', { + bubbles: true, + inputType: nextText ? 'insertText' : 'deleteContentBackward', + data: nextText || null + })); + } } - await sleepInPage(700); + function restoreComposerIfUnchanged(element, originalText, injectedText) { + const currentText = getComposerText(element); + if (normalizeComposerText(currentText) !== normalizeComposerText(injectedText)) { + return { + restored: false, + draftPreserved: true, + currentTextLength: currentText.length + }; + } - const sendButtonMatch = findMatch('sendButton', isSendActionCandidate); - const sendButton = sendButtonMatch.element; + try { + setComposerText(element, originalText); + return { + restored: true, + draftPreserved: false, + currentTextLength: originalText.length + }; + } catch { + return { + restored: false, + draftPreserved: false, + restoreFailed: true, + currentTextLength: currentText.length + }; + } + } - if (!sendButton) { - return compatibilityFailure('sendButton', 'required send action signal was not found.', { - composerSelector: inputMatch.selector, - composerSignal: inputMatch.signalKey, - composer: describeElement(input) + const inputMatch = findMatch('composer', isComposerCandidate); + const input = inputMatch.element; + + if (!input) { + return compatibilityFailure('composer', 'required composer signal was not found.', { + activeElement: describeElement(document.activeElement) }); } - if (sendButton.disabled || sendButton.getAttribute('aria-disabled') === 'true') { - return compatibilityFailure('sendButton', 'send action signal is disabled.', { + const originalComposerText = getComposerText(input); + if (normalizeComposerText(originalComposerText)) { + return compatibilityFailure('composer', 'canonical composer contains pending user content; queued send deferred to preserve the draft.', { composerSelector: inputMatch.selector, composerSignal: inputMatch.signalKey, - sendButtonSelector: sendButtonMatch.selector, - sendButtonSignal: sendButtonMatch.signalKey, composer: describeElement(input), - sendButton: describeElement(sendButton) + composerConflict: true, + deferred: true, + pendingTextLength: originalComposerText.length }); } - sendButton.click(); + const queuedText = String(msg || ''); + let composerWriteStarted = false; + + try { + composerWriteStarted = true; + setComposerText(input, queuedText); + await sleepInPage(700); + + const currentComposerText = getComposerText(input); + if (normalizeComposerText(currentComposerText) !== normalizeComposerText(queuedText)) { + return compatibilityFailure('composer', 'canonical composer changed while the queued message was pending; send deferred to preserve the draft.', { + composerSelector: inputMatch.selector, + composerSignal: inputMatch.signalKey, + composer: describeElement(input), + composerConflict: true, + deferred: true, + pendingTextLength: currentComposerText.length, + draftPreserved: true + }); + } - return { - ok: true, - details: { + const sendButtonMatch = findMatch('sendButton', isSendActionCandidate); + const sendButton = sendButtonMatch.element; + + if (!sendButton) { + const restoration = restoreComposerIfUnchanged(input, originalComposerText, queuedText); + return compatibilityFailure('sendButton', 'required send action signal was not found.', { + composerSelector: inputMatch.selector, + composerSignal: inputMatch.signalKey, + composer: describeElement(input), + ...restoration + }); + } + + if (sendButton.disabled || sendButton.getAttribute('aria-disabled') === 'true') { + const restoration = restoreComposerIfUnchanged(input, originalComposerText, queuedText); + return compatibilityFailure('sendButton', 'send action signal is disabled.', { + composerSelector: inputMatch.selector, + composerSignal: inputMatch.signalKey, + sendButtonSelector: sendButtonMatch.selector, + sendButtonSignal: sendButtonMatch.signalKey, + composer: describeElement(input), + sendButton: describeElement(sendButton), + ...restoration + }); + } + + sendButton.click(); + + return { + ok: true, + details: { + composerSelector: inputMatch.selector, + composerSignal: inputMatch.signalKey, + sendButtonSelector: sendButtonMatch.selector, + sendButtonSignal: sendButtonMatch.signalKey, + messageLength: queuedText.length, + url: location.href, + title: document.title, + provider: providerName || '' + } + }; + } catch (error) { + const restoration = composerWriteStarted + ? restoreComposerIfUnchanged(input, originalComposerText, queuedText) + : { restored: false, draftPreserved: false }; + return compatibilityFailure('submission', 'queued message submission failed; the composer draft was preserved when it was safe to restore.', { composerSelector: inputMatch.selector, composerSignal: inputMatch.signalKey, - sendButtonSelector: sendButtonMatch.selector, - sendButtonSignal: sendButtonMatch.signalKey, - messageLength: String(msg || '').length, - url: location.href, - title: document.title, - provider: providerName || '' - } - }; + composer: describeElement(input), + error: error?.message || 'Unknown submission error', + ...restoration + }); + } }, args: [text, compatibilityContract, providerName] }); diff --git a/content.js b/content.js index f35afb5..e89df87 100644 --- a/content.js +++ b/content.js @@ -552,6 +552,7 @@ this.state.inlineQueueInFlight = true; this.state.lastQueuedAt = Date.now(); this.state.lastQueuedText = text; + const queuedText = text; if (typeof chrome !== 'undefined' && chrome.runtime?.sendMessage) { try { @@ -583,7 +584,17 @@ if (diagnostic) { diagnostic.enqueueResult = 'success'; } - this.clearComposer(composer); + + // Only clear the text that was captured for this enqueue. If the + // user edited the composer while the background acknowledged the + // queue request, keep the newer draft instead of clearing it. + const currentText = this.getComposerText(composer); + if (currentText === queuedText) { + this.clearComposer(composer); + } else if (diagnostic) { + diagnostic.composerDraftPreserved = true; + } + this.showInlineQueueToast('Queued to send after the current response.'); }); } catch (err) { diff --git a/provider-adapter.js b/provider-adapter.js index f8defa1..cd262e9 100644 --- a/provider-adapter.js +++ b/provider-adapter.js @@ -967,7 +967,12 @@ return composer.value || ''; } - return composer.innerText || composer.textContent || ''; + const directText = composer.innerText || composer.textContent || ''; + if (directText) return String(directText); + + return Array.from(composer.children || []) + .map(child => child.innerText || child.textContent || '') + .join(''); } clearComposer(composer) { @@ -980,6 +985,12 @@ if (composer.tagName && composer.tagName.toLowerCase() === 'textarea') { composer.value = ''; } else { + if (typeof composer.replaceChildren === 'function') { + composer.replaceChildren(); + } else if (Array.isArray(composer.children)) { + composer.children.length = 0; + } + composer.innerText = ''; composer.textContent = ''; } diff --git a/test/content-enter.test.js b/test/content-enter.test.js index 17d3130..0b4e106 100644 --- a/test/content-enter.test.js +++ b/test/content-enter.test.js @@ -736,6 +736,41 @@ test('Enqueue failure keeps typed composer text recoverable and stops keypress l assert.ok(lastDiag.enqueueResult.startsWith('error:')); }); +test('Successful enqueue keeps a composer draft that changed before the queue ACK', async () => { + const { dom, optimizer, sentMessages } = setupTestEnv(); + + const composer = dom.document.createElement('div'); + composer.id = 'prompt-textarea'; + composer.setAttribute('contenteditable', 'true'); + composer.innerText = 'Original queued prompt'; + composer.textContent = 'Original queued prompt'; + dom.document.body.appendChild(composer); + + const stopButton = dom.document.createElement('button'); + stopButton.setAttribute('data-testid', 'stop-button'); + dom.document.body.appendChild(stopButton); + + const event = new dom.MockKeyboardEvent('keydown', { key: 'Enter' }); + composer.dispatchEvent(event); + + composer.innerText = 'Newer user draft'; + composer.textContent = 'Newer user draft'; + + await new Promise(r => { + setTimeout(r, 10); + }); + + const enqueueMsg = sentMessages.find(m => m.action === 'enqueueMessage'); + assert.ok(enqueueMsg); + assert.strictEqual(enqueueMsg.message, 'Original queued prompt'); + assert.strictEqual(composer.innerText, 'Newer user draft'); + assert.strictEqual(composer.textContent, 'Newer user draft'); + + const lastDiag = optimizer.state.enterDiagnostics[optimizer.state.enterDiagnostics.length - 1]; + assert.strictEqual(lastDiag.enqueueResult, 'success'); + assert.strictEqual(lastDiag.composerDraftPreserved, true); +}); + test('Rapid duplicate Enter is protected by in-flight and debounce rules', async () => { const { dom, optimizer, sentMessages } = setupTestEnv(); diff --git a/test/provider-adapter.test.js b/test/provider-adapter.test.js index 7f19d86..50b2a1b 100644 --- a/test/provider-adapter.test.js +++ b/test/provider-adapter.test.js @@ -907,6 +907,221 @@ test('Queued ChatGPT send uses the canonical composer and reports compatibility } }); +function createCanonicalComposerDoc({ + composerTag = 'textarea', + composerAttrs = { id: 'prompt-textarea' }, + composerText = '', + includeSendButton = true, + sendButtonDisabled = false +} = {}) { + const doc = new MockTestElement('body'); + doc.title = 'ChatGPT'; + doc.createElement = (tag) => new MockTestElement(tag); + doc.activeElement = null; + const composer = new MockTestElement(composerTag, composerAttrs); + if ((composerTag || '').toLowerCase() === 'textarea') { + composer.value = composerText; + } else { + composer.innerText = composerText; + composer.textContent = composerText; + } + doc.appendChild(composer); + let sendButton = null; + if (includeSendButton) { + sendButton = new MockTestElement('button', { 'data-testid': 'send-button' }); + sendButton.disabled = sendButtonDisabled; + if (sendButtonDisabled) { + sendButton.setAttribute('aria-disabled', 'true'); + } + doc.appendChild(sendButton); + } + return { doc, composer, sendButton }; +} + +async function withQueuedSendScript(run) { + const originalExecuteScript = chrome.scripting.executeScript; + const originalDocument = global.document; + const originalLocation = global.location; + const originalInputEvent = global.InputEvent; + let submittedText = ''; + try { + chrome.scripting.executeScript = async (details) => { + const result = await details.func(...details.args); + if (result?.ok) { + submittedText = String(details.args?.[0] || ''); + } + return [{ result }]; + }; + global.InputEvent = class { + constructor(type, init) { + this.type = type; + this.init = init; + } + }; + global.location = { href: 'https://chatgpt.com/c/issue-42-composer' }; + mockTabs.set(902, { + id: 902, + url: global.location.href, + onMessage: (message) => { + if (message?.type !== 'GET_COMMAND_TURN_SNAPSHOT') { + return { ok: true }; + } + if (!submittedText) { + return { + ok: true, + snapshot: { + userTurns: [], + latestUserTurnId: null, + matchedUserTurnId: null + } + }; + } + const expected = String(message.expectedText || submittedText).replace(/\s+/g, ' ').trim(); + return { + ok: true, + snapshot: { + userTurns: [{ + turnId: 'user-queued-42', + index: 0, + fingerprint: `len:${expected.length}`, + matchedExpected: true + }], + latestUserTurnId: 'user-queued-42', + matchedUserTurnId: 'user-queued-42' + } + }; + } + }); + return await run(); + } finally { + chrome.scripting.executeScript = originalExecuteScript; + if (originalDocument === undefined) delete global.document; + else global.document = originalDocument; + if (originalLocation === undefined) delete global.location; + else global.location = originalLocation; + if (originalInputEvent === undefined) delete global.InputEvent; + else global.InputEvent = originalInputEvent; + mockTabs.delete(902); + } +} + +test('ChatGPTAdapter inspects canonical composer drafts without mutating them', () => { + const chatgpt = getProvider('chatgpt'); + const composer = new MockTestElement('textarea', { id: 'prompt-textarea' }); + composer.value = 'pending user draft'; + const nestedComposer = new MockTestElement('div', { + 'data-testid': 'prompt-textarea', + contenteditable: 'true' + }); + const nestedDraft = new MockTestElement('p', {}, 'nested pending draft'); + nestedComposer.appendChild(nestedDraft); + const sendButton = new MockTestElement('button', { 'data-testid': 'send-button' }); + const { doc } = createTestDoc({ extraNodes: [composer, sendButton] }); + + const match = chatgpt.getComposerMatch(doc); + assert.equal(match.element, composer); + assert.equal(chatgpt.getComposerText(match.element), 'pending user draft'); + assert.equal(composer.value, 'pending user draft'); + assert.equal(chatgpt.getComposerText(nestedComposer), 'nested pending draft'); + assert.equal(nestedDraft.innerText, 'nested pending draft'); +}); + +test('Queued send accepts a clean empty canonical composer', async () => { + await withQueuedSendScript(async () => { + const { doc, composer, sendButton } = createCanonicalComposerDoc({ + composerText: '\u200b' + }); + global.document = doc; + + const sent = await sendPromptToSpecificTab(902, 'queued prompt'); + assert.equal(sent.ok, true); + assert.equal(composer.value, 'queued prompt'); + assert.equal(sendButton.clicked, true); + assert.equal(sent.details.composerSelector, '#prompt-textarea'); + }); +}); + +test('Queued send never overwrites a non-empty canonical composer draft', async () => { + await withQueuedSendScript(async () => { + const { doc, composer, sendButton } = createCanonicalComposerDoc({ + composerText: 'keep my unsent draft' + }); + global.document = doc; + + const failed = await sendPromptToSpecificTab(902, 'queued prompt'); + assert.equal(failed.ok, false); + assert.match(failed.error, /pending user content/); + assert.equal(failed.details.compatibilityFailure, 'composer'); + assert.equal(failed.details.composerConflict, true); + assert.equal(failed.details.deferred, true); + assert.equal(composer.value, 'keep my unsent draft'); + assert.equal(sendButton.clicked, false); + }); +}); + +test('Queued send restores the preflight composer after send-button failure', async () => { + await withQueuedSendScript(async () => { + const missingSend = createCanonicalComposerDoc({ includeSendButton: false }); + global.document = missingSend.doc; + + const missing = await sendPromptToSpecificTab(902, 'queued prompt'); + assert.equal(missing.ok, false); + assert.match(missing.error, /send action signal was not found/); + assert.equal(missing.details.compatibilityFailure, 'sendButton'); + assert.equal(missing.details.restored, true); + assert.equal(missingSend.composer.value, ''); + + const disabledSend = createCanonicalComposerDoc({ sendButtonDisabled: true }); + global.document = disabledSend.doc; + + const disabled = await sendPromptToSpecificTab(902, 'queued prompt'); + assert.equal(disabled.ok, false); + assert.match(disabled.error, /send action signal is disabled/); + assert.equal(disabled.details.compatibilityFailure, 'sendButton'); + assert.equal(disabled.details.restored, true); + assert.equal(disabledSend.composer.value, ''); + assert.equal(disabledSend.sendButton.clicked, false); + }); +}); + +test('Queued send defers on conflicting pending composer state', async () => { + await withQueuedSendScript(async () => { + const pendingChild = createCanonicalComposerDoc({ + composerTag: 'div', + composerAttrs: { + id: 'prompt-textarea', + contenteditable: 'true' + } + }); + pendingChild.composer.appendChild(new MockTestElement('p', {}, 'hidden child draft')); + global.document = pendingChild.doc; + + const childConflict = await sendPromptToSpecificTab(902, 'queued prompt'); + assert.equal(childConflict.ok, false); + assert.match(childConflict.error, /pending user content/); + assert.equal(childConflict.details.composerConflict, true); + assert.equal(childConflict.details.deferred, true); + assert.equal(pendingChild.composer.children[0].innerText, 'hidden child draft'); + assert.equal(pendingChild.sendButton.clicked, false); + + const liveEdit = createCanonicalComposerDoc(); + global.document = liveEdit.doc; + const pending = sendPromptToSpecificTab(902, 'queued prompt'); + await new Promise(resolve => { + setTimeout(resolve, 50); + }); + liveEdit.composer.value = 'user typed during queued send'; + const changed = await pending; + assert.equal(changed.ok, false); + assert.match(changed.error, /changed while the queued message was pending/); + assert.equal(changed.details.composerConflict, true); + assert.equal(changed.details.deferred, true); + assert.equal(changed.details.draftPreserved, true); + assert.equal(liveEdit.composer.value, 'user typed during queued send'); + assert.equal(liveEdit.sendButton.clicked, false); + }); +}); + test('ChatGPTAdapter detects delivery timeout distinct from generic errors', () => { const chatgpt = getProvider('chatgpt');