Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
208 changes: 159 additions & 49 deletions background.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]
});
Expand Down
13 changes: 12 additions & 1 deletion content.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down
13 changes: 12 additions & 1 deletion provider-adapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 = '';
}

Expand Down
35 changes: 35 additions & 0 deletions test/content-enter.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
Loading
Loading