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
59 changes: 45 additions & 14 deletions src/chrome/src/ui/sidepanel.js
Original file line number Diff line number Diff line change
Expand Up @@ -3021,6 +3021,35 @@ async function settleScheduledRun(event, job, tabId = currentTabId) {
}
}

function renderScheduledJobCreatedMessage(job, preferredMessage = null, root = messagesEl) {
const jobId = job?.id ? String(job.id) : '';
if (jobId) {
const alreadyRendered = Array.from(
root?.querySelectorAll?.('.message.system[data-scheduled-created-job-id]') || [],
).find((message) => message.dataset.scheduledCreatedJobId === jobId);
if (alreadyRendered) {
if (preferredMessage && preferredMessage !== alreadyRendered) preferredMessage.remove();
return alreadyRendered;
}
}

const title = scheduledJobTitle(job);
const createdParams = {
title,
time: formatScheduledTime(job.nextRunAt || job.scheduledAt),
};
const createdHtml = preferredMessage
? tSystemHtml('sp.schedule_form.created', createdParams)
: tSystemHtml('sp.scheduled.created', createdParams);
const message = preferredMessage || addMessage('system', systemHtml(createdHtml));
const textEl = preferredMessage?.querySelector('.message-text');
if (textEl) textEl.innerHTML = createdHtml;
// Runtime delivery and restored chat can converge on the same presentation
// event. Persist the job identity in the DOM so remounts remain idempotent.
if (jobId) message.dataset.scheduledCreatedJobId = jobId;
return message;
}

async function handleScheduledJobEvent(data, tabId) {
refreshScheduledJobs({ tabId: currentTabId });
const event = data?.event;
Expand Down Expand Up @@ -3048,7 +3077,7 @@ async function handleScheduledJobEvent(data, tabId) {

const title = scheduledJobTitle(job);
if (event === 'created') {
addMessage('system', systemHtml(tSystemHtml('sp.scheduled.created', { title, time: formatScheduledTime(job.nextRunAt || job.scheduledAt) })));
renderScheduledJobCreatedMessage(job);
} else if (event === 'running') {
clearActiveChatPayloadForTab(runTabId);
setTabProcessing(runTabId, true);
Expand Down Expand Up @@ -3251,20 +3280,21 @@ async function submitScheduleComposer(e, form) {
if (res?.success === false || res?.ok === false || !res?.scheduledAt) {
throw new Error(res?.error || 'Could not create scheduled job.');
}
const createdHtml = tSystemHtml('sp.schedule_form.created', {
title,
time: formatScheduledTime(res.scheduledAt),
});
if (currentTabId !== tabId) {
replaceCachedScheduleComposer(tabId, form.dataset.composerId, createdHtml);
replaceCachedScheduleComposer(tabId, form.dataset.composerId, {
id: res.jobId,
title,
scheduledAt: res.scheduledAt,
});
return;
}
const msgEl = form.closest('.message');
form.remove();
const textEl = msgEl?.querySelector('.message-text');
if (textEl) {
textEl.innerHTML = createdHtml;
}
renderScheduledJobCreatedMessage({
id: res.jobId,
title,
scheduledAt: res.scheduledAt,
}, msgEl);
await refreshScheduledJobs({ tabId });
} catch (err) {
if (currentTabId !== tabId) {
Expand All @@ -3288,16 +3318,17 @@ function bindScheduleComposer(form) {
form.addEventListener('submit', (e) => submitScheduleComposer(e, form));
}

function replaceCachedScheduleComposer(tabId, composerId, html) {
function replaceCachedScheduleComposer(tabId, composerId, job) {
const cached = tabChats.get(tabId);
if (typeof cached !== 'string' || !composerId) return;
const wrapper = document.createElement('div');
wrapper.innerHTML = cached;
const form = wrapper.querySelector(`form.schedule-composer[data-composer-id="${composerId}"]`);
const textEl = form?.closest('.message')?.querySelector('.message-text');
if (!form || !textEl) return;
const msgEl = form?.closest('.message');
const textEl = msgEl?.querySelector('.message-text');
if (!form || !msgEl || !textEl) return;
form.remove();
textEl.innerHTML = html;
renderScheduledJobCreatedMessage(job, msgEl, wrapper);
persistTabChat(tabId, wrapper.innerHTML);
}

Expand Down
59 changes: 45 additions & 14 deletions src/firefox/src/ui/sidepanel.js
Original file line number Diff line number Diff line change
Expand Up @@ -2875,6 +2875,35 @@ async function settleScheduledRun(event, job, tabId = currentTabId) {
}
}

function renderScheduledJobCreatedMessage(job, preferredMessage = null, root = messagesEl) {
const jobId = job?.id ? String(job.id) : '';
if (jobId) {
const alreadyRendered = Array.from(
root?.querySelectorAll?.('.message.system[data-scheduled-created-job-id]') || [],
).find((message) => message.dataset.scheduledCreatedJobId === jobId);
if (alreadyRendered) {
if (preferredMessage && preferredMessage !== alreadyRendered) preferredMessage.remove();
return alreadyRendered;
}
}

const title = scheduledJobTitle(job);
const createdParams = {
title,
time: formatScheduledTime(job.nextRunAt || job.scheduledAt),
};
const createdHtml = preferredMessage
? tSystemHtml('sp.schedule_form.created', createdParams)
: tSystemHtml('sp.scheduled.created', createdParams);
const message = preferredMessage || addMessage('system', systemHtml(createdHtml));
const textEl = preferredMessage?.querySelector('.message-text');
if (textEl) textEl.innerHTML = createdHtml;
// Runtime delivery and restored chat can converge on the same presentation
// event. Persist the job identity in the DOM so remounts remain idempotent.
if (jobId) message.dataset.scheduledCreatedJobId = jobId;
return message;
}

async function handleScheduledJobEvent(data, tabId) {
refreshScheduledJobs({ tabId: currentTabId });
const event = data?.event;
Expand Down Expand Up @@ -2902,7 +2931,7 @@ async function handleScheduledJobEvent(data, tabId) {

const title = scheduledJobTitle(job);
if (event === 'created') {
addMessage('system', systemHtml(tSystemHtml('sp.scheduled.created', { title, time: formatScheduledTime(job.nextRunAt || job.scheduledAt) })));
renderScheduledJobCreatedMessage(job);
} else if (event === 'running') {
clearActiveChatPayloadForTab(runTabId);
setTabProcessing(runTabId, true);
Expand Down Expand Up @@ -3105,20 +3134,21 @@ async function submitScheduleComposer(e, form) {
if (res?.success === false || res?.ok === false || !res?.scheduledAt) {
throw new Error(res?.error || 'Could not create scheduled job.');
}
const createdHtml = tSystemHtml('sp.schedule_form.created', {
title,
time: formatScheduledTime(res.scheduledAt),
});
if (currentTabId !== tabId) {
replaceCachedScheduleComposer(tabId, form.dataset.composerId, createdHtml);
replaceCachedScheduleComposer(tabId, form.dataset.composerId, {
id: res.jobId,
title,
scheduledAt: res.scheduledAt,
});
return;
}
const msgEl = form.closest('.message');
form.remove();
const textEl = msgEl?.querySelector('.message-text');
if (textEl) {
textEl.innerHTML = createdHtml;
}
renderScheduledJobCreatedMessage({
id: res.jobId,
title,
scheduledAt: res.scheduledAt,
}, msgEl);
await refreshScheduledJobs({ tabId });
} catch (err) {
if (currentTabId !== tabId) {
Expand All @@ -3142,16 +3172,17 @@ function bindScheduleComposer(form) {
form.addEventListener('submit', (e) => submitScheduleComposer(e, form));
}

function replaceCachedScheduleComposer(tabId, composerId, html) {
function replaceCachedScheduleComposer(tabId, composerId, job) {
const cached = tabChats.get(tabId);
if (typeof cached !== 'string' || !composerId) return;
const wrapper = document.createElement('div');
wrapper.innerHTML = cached;
const form = wrapper.querySelector(`form.schedule-composer[data-composer-id="${composerId}"]`);
const textEl = form?.closest('.message')?.querySelector('.message-text');
if (!form || !textEl) return;
const msgEl = form?.closest('.message');
const textEl = msgEl?.querySelector('.message-text');
if (!form || !msgEl || !textEl) return;
form.remove();
textEl.innerHTML = html;
renderScheduledJobCreatedMessage(job, msgEl, wrapper);
persistTabChat(tabId, wrapper.innerHTML);
}

Expand Down
86 changes: 84 additions & 2 deletions test/run.js
Original file line number Diff line number Diff line change
Expand Up @@ -31921,7 +31921,7 @@ test('sidepanel exposes schedule slash commands in both builds', () => {
assert.match(panel, /currentAssistantEl\.dataset\?\.scheduledJobId === scheduledJobId/, `${label}: scheduled clarify submission should not steal an unrelated active reply`);
assert.match(panel, /res\?\.success === false \|\| res\?\.ok === false \|\| !res\?\.scheduledAt/, `${label}: schedule form should reject failed create responses before showing success`);
assert.match(panel, /async function getCurrentScheduleUrl\(tabId = currentTabId\)/, `${label}: schedule URL lookup should accept a captured tab id`);
assert.match(panel, /function replaceCachedScheduleComposer\(tabId, composerId, html\) \{[\s\S]*?form\.remove\(\);[\s\S]*?textEl\.innerHTML = html;[\s\S]*?\}/, `${label}: completed off-tab schedule creates should update cached composer HTML`);
assert.match(panel, /function replaceCachedScheduleComposer\(tabId, composerId, job\) \{[\s\S]*?form\.remove\(\);[\s\S]*?renderScheduledJobCreatedMessage\(job, msgEl, wrapper\);[\s\S]*?persistTabChat\(tabId, wrapper\.innerHTML\);[\s\S]*?\}/, `${label}: completed off-tab schedule creates should reconcile cached composer messages by job id`);
assert.match(panel, /function updateCachedScheduleComposerError\(tabId, composerId, message\) \{[\s\S]*?form\.schedule-composer\[data-composer-id="\$\{composerId\}"\][\s\S]*?submit\.disabled = false;[\s\S]*?errorEl\.textContent = message \|\| '';[\s\S]*?\}/, `${label}: failed off-tab schedule creates should re-enable cached composers with the error`);
assert.match(panel, /async function renderScheduleComposer\(prefillPrompt = '', tabId = currentTabId\)/, `${label}: schedule form should capture the requested tab`);
assert.match(panel, /const initialScheduleUrl = await getCurrentScheduleUrl\(tabId\);[\s\S]*?if \(currentTabId !== tabId\) return;[\s\S]*?addMessage\('system', t\('sp\.schedule_form\.opened'\)\)/, `${label}: schedule form should resolve target defaults before rendering and drop stale tab switches`);
Expand All @@ -31932,7 +31932,7 @@ test('sidepanel exposes schedule slash commands in both builds', () => {
assert.match(panel, /function bindScheduleComposer\(form\) \{[\s\S]*?form\.dataset\.bound = 'true';[\s\S]*?form\.addEventListener\('submit', \(e\) => submitScheduleComposer\(e, form\)\);[\s\S]*?\}/, `${label}: schedule composer listeners should be reusable after serialized restore`);
assert.match(panel, /bindScheduleComposer\(form\);[\s\S]*?content\.appendChild\(form\)/, `${label}: initial schedule composer render should use the reusable binder`);
assert.match(panel, /create_scheduled_job'[\s\S]*?\{\s*tabId,[\s\S]*?job:/, `${label}: schedule form should create jobs for the captured tab`);
assert.match(panel, /const createdHtml = tSystemHtml\('sp\.schedule_form\.created'[\s\S]*?if \(currentTabId !== tabId\) \{[\s\S]*?replaceCachedScheduleComposer\(tabId, form\.dataset\.composerId, createdHtml\);[\s\S]*?return;[\s\S]*?\}[\s\S]*?form\.remove\(\);/, `${label}: schedule form should update hidden cached composers instead of leaving stale disabled forms`);
assert.match(panel, /if \(currentTabId !== tabId\) \{[\s\S]*?replaceCachedScheduleComposer\(tabId, form\.dataset\.composerId, \{[\s\S]*?id: res\.jobId,[\s\S]*?scheduledAt: res\.scheduledAt,[\s\S]*?\}\);[\s\S]*?return;[\s\S]*?\}[\s\S]*?form\.remove\(\);[\s\S]*?renderScheduledJobCreatedMessage/, `${label}: schedule form should reconcile hidden and visible composer confirmations by job id`);
assert.match(panel, /catch \(err\) \{[\s\S]*?if \(currentTabId !== tabId\) \{[\s\S]*?updateCachedScheduleComposerError\(tabId, form\.dataset\.composerId, err\.message\);[\s\S]*?return;[\s\S]*?\}[\s\S]*?submit\.disabled = false;[\s\S]*?errorEl\.textContent = err\.message;/, `${label}: schedule form failures should update hidden cached composers instead of leaving disabled forms`);
assert.match(panel, /renderScheduleComposer\(payload, tabId\)/, `${label}: /schedule should pass the initiating tab into the async composer`);
assert.match(panel, /urlInput\.value = initialScheduleUrl/, `${label}: schedule form should prefill URL targets from the active tab`);
Expand Down Expand Up @@ -40098,6 +40098,88 @@ test('sidepanel keeps scheduled job action errors on the initiating tab', () =>
}
});

test('sidepanel renders one created message per scheduled job', () => {
for (const [label, panelRel] of [
['chrome', 'src/chrome/src/ui/sidepanel.js'],
['firefox', 'src/firefox/src/ui/sidepanel.js'],
]) {
const panel = fs.readFileSync(path.join(ROOT, panelRel), 'utf8');
const start = panel.indexOf('function renderScheduledJobCreatedMessage(job, preferredMessage = null, root = messagesEl) {');
const end = panel.indexOf('\n}\n\nasync function handleScheduledJobEvent', start);
assert.notEqual(start, -1, `${label}: scheduled created-message renderer missing`);
assert.notEqual(end, -1, `${label}: scheduled created-message renderer boundary missing`);

const rendered = [];
const fakeMessage = (content = '') => {
const textEl = { innerHTML: content };
const element = {
content,
dataset: {},
querySelector(selector) { return selector === '.message-text' ? textEl : null; },
remove() {
const index = rendered.indexOf(element);
if (index >= 0) rendered.splice(index, 1);
},
};
return element;
};
const messagesEl = {
querySelectorAll() { return rendered; },
};
const renderCreated = Function(
'messagesEl',
'addMessage',
'systemHtml',
'tSystemHtml',
'scheduledJobTitle',
'formatScheduledTime',
`${panel.slice(start, end + 2)}\nreturn renderScheduledJobCreatedMessage;`,
)(
messagesEl,
(_role, content) => {
const element = fakeMessage(content);
rendered.push(element);
return element;
},
(value) => value,
(_key, values) => `${values.title}|${values.time}`,
(job) => job.title,
(value) => value,
);

const first = { id: 'task_1', title: 'Follow up', scheduledAt: '2026-08-20T14:53:00.000Z' };
renderCreated(first);
const eventFirstComposer = fakeMessage('schedule form');
rendered.push(eventFirstComposer);
renderCreated(first, eventFirstComposer);

assert.equal(rendered.length, 1, `${label}: an event-first composer confirmation should reuse the event message`);

rendered.length = 0;
const composerFirst = fakeMessage('schedule form');
rendered.push(composerFirst);
renderCreated(first, composerFirst);
renderCreated(first);
renderCreated({ ...first, id: 'task_2' });

assert.equal(rendered.length, 2, `${label}: duplicate created events should reuse the existing message`);
assert.deepEqual(
rendered.map((element) => element.dataset.scheduledCreatedJobId),
['task_1', 'task_2'],
`${label}: rendered messages should retain their job identity across chat persistence`,
);

const submitStart = panel.indexOf('async function submitScheduleComposer(e, form) {');
const submitEnd = panel.indexOf('\n}\n\nfunction bindScheduleComposer', submitStart);
const submitBody = panel.slice(submitStart, submitEnd);
assert.match(
submitBody,
/renderScheduledJobCreatedMessage\(\{\s*id: res\.jobId,[\s\S]*?title,[\s\S]*?scheduledAt: res\.scheduledAt,[\s\S]*?\}, msgEl\);/,
`${label}: schedule composer success should reconcile with the created event by job id`,
);
}
});

test('background awaits context-menu prompt clear before agent chat starts', () => {
for (const [label, bgRel] of [
['chrome', 'src/chrome/src/background.js'],
Expand Down
Loading