fix(webui): sp6 review round, a stopped organization's pane stops reading and keeps its unsent message, an open pane picks up new replies on its own, a pasted thread id in any case shows its pane (#1873, lane sp6-threads)

A pane whose organization's bus is stopped or unavailable no longer polls or offers Refresh; it says
nothing is read until the bus starts again, holds any typed draft, and restores it untouched when the
bus returns. Unsent pane drafts now count for the organization-switch and Console-restart guards.
The Assistant sidebar item drops the Messages unread badge. "Start thread" is the primary action.
Tests cover the stopped pane's draft, a reply arriving with no refresh, and an uppercase show id.
This commit is contained in:
jason.woltje committed 2026-11-15 04:51:23 -06:00
1 parent 5dd940165d
commit b982404963
7 files changed
+69 -24

No files matched your search

+2
View File
@@ -23,6 +23,8 @@ html[data-organizations] .assist-new-fields{display:grid;grid-template-columns:r
html[data-organizations] .assist-new-fields label{display:grid;gap:4px;font-size:13px;color:var(--muted);min-width:0} html[data-organizations] .assist-new-fields label{display:grid;gap:4px;font-size:13px;color:var(--muted);min-width:0}
html[data-organizations] .assist-new-fields select,html[data-organizations] .assist-new-fields input{font:500 14px/1.4 var(--font);color:var(--text);background:var(--canvas);border:1px solid var(--line);border-radius:var(--r);padding:6px 8px;min-width:0} html[data-organizations] .assist-new-fields select,html[data-organizations] .assist-new-fields input{font:500 14px/1.4 var(--font);color:var(--text);background:var(--canvas);border:1px solid var(--line);border-radius:var(--r);padding:6px 8px;min-width:0}
html[data-organizations] .assist-new-actions{display:flex;gap:8px;flex-wrap:wrap} html[data-organizations] .assist-new-actions{display:flex;gap:8px;flex-wrap:wrap}
html[data-organizations] .assist-new .ws-btn.primary{background:var(--action);color:var(--onAction);border-color:var(--action)}
html[data-organizations] .assist-new .ws-btn.primary:hover:not(:disabled){background:color-mix(in srgb,var(--action) 88%,var(--strong))}
html[data-organizations] .assist-new [data-assist-new-status]{margin:0;font-size:13px} html[data-organizations] .assist-new [data-assist-new-status]{margin:0;font-size:13px}
html[data-organizations] .assist-new [data-assist-new-status]:empty{display:none} html[data-organizations] .assist-new [data-assist-new-status]:empty{display:none}
+23 -9
View File
@@ -36,7 +36,8 @@
const key = `${org}:${id}`; const key = `${org}:${id}`;
if (!keys.includes(key) && keys.length < MAX) keys.push(key); if (!keys.includes(key) && keys.length < MAX) keys.push(key);
} }
const show = q.get('show'); const want = q.get('show') || '', at = want.indexOf(':');
const show = at > 0 ? `${want.slice(0, at)}:${want.slice(at + 1).toLowerCase()}` : null;
return { keys, show: keys.includes(show) ? show : null, named: q.has('open') }; return { keys, show: keys.includes(show) ? show : null, named: q.has('open') };
} }
const split = key => { const cut = key.indexOf(':'); return { org: key.slice(0, cut), id: key.slice(cut + 1) }; }; const split = key => { const cut = key.indexOf(':'); return { org: key.slice(0, cut), id: key.slice(cut + 1) }; };
@@ -185,7 +186,7 @@
function paintTop() { function paintTop() {
const { root, list, o, panes } = mounted; const { root, list, o, panes } = mounted;
setHtml(root.querySelector('[data-assist-unread]'), list ? ws().unreadNote(o, [], 'assistant') : '<p class="note ws-unread-note">The organization list could not be read. Open panes only read.</p>'); setHtml(root.querySelector('[data-assist-unread]'), list ? ws().unreadNote(o, [], 'assistant') : '<p class="note ws-unread-note">The organization list could not be read. Open panes only read.</p>');
setText(root.querySelector('[data-assist-note]'), panes.length ? `${panes.length} of ${MAX} open` : ''); setText(root.querySelector('[data-assist-note]'), panes.length ? `${panes.length} open, up to ${MAX}` : '');
const pick = root.querySelector('[data-assist-open]'); const pick = root.querySelector('[data-assist-open]');
if (document.activeElement !== pick) { setHtml(pick, pickerHtml(list, mounted.got)); pick.value = ''; } if (document.activeElement !== pick) { setHtml(pick, pickerHtml(list, mounted.got)); pick.value = ''; }
const full = panes.length >= MAX; const full = panes.length >= MAX;
@@ -213,21 +214,34 @@
if (org) p.el.setAttribute('style', ws().colours(org)); else p.el.removeAttribute('style'); if (org) p.el.setAttribute('style', ws().colours(org)); else p.el.removeAttribute('style');
const project = thread && i.projects?.find(x => x.id === thread.project)?.name; const project = thread && i.projects?.find(x => x.id === thread.project)?.name;
setHtml(e.chips, `${ws().orgChip(o, p.org)}${ws().projChip(project || '')}`); setHtml(e.chips, `${ws().orgChip(o, p.org)}${ws().projChip(project || '')}`);
let line = '', readable = true; let line = '', readable = true, why = 'Nothing is read for this thread.';
if (!list) line = 'The organization list could not be read, so sending is off here until it reads again.'; if (!list) line = 'The organization list could not be read, so sending is off here until it reads again.';
else if (!org) { line = `${p.org} is not on your organization list.`; readable = false; } else if (!org) { line = `${p.org} is not on your organization list.`; readable = false; }
else if (org.invalid) { line = `${name}: organization invalid. Fix its file under Settings, Organizations.`; readable = false; } else if (org.invalid) { line = `${name}: organization invalid. Fix its file under Settings, Organizations.`; readable = false; }
else if (org.busHealthy === false) line = `${name}: bus is ${org.busStopped ? 'stopped' : 'unavailable'}. This pane cannot read or send until it starts again; the other panes are not affected.`; else if (org.busHealthy === false) {
const how = org.busStopped ? 'stopped' : 'unavailable';
line = `${name}: bus is ${how}. This pane cannot read or send until it starts again; the other panes are not affected.`;
why = `Nothing is read for this thread while ${name}'s bus is ${how}.`; readable = false;
}
else if (i?.threads && !thread) line = `This thread is not in ${name}'s thread list.`; else if (i?.threads && !thread) line = `This thread is not in ${name}'s thread list.`;
setText(e.line, line); e.line.hidden = !line; setText(e.line, line); e.line.hidden = !line;
if (!readable) { if (!readable) {
// An unsent message outlives the chat view: it is held on the pane and put back when the
// thread reads again.
const typed = p.chat && e.body.querySelector('form textarea')?.value;
if (typed?.trim()) p.draft = typed;
p.chat?.dispose(); p.chat = null; p.chat?.dispose(); p.chat = null;
setHtml(e.body, '<p class="ws-line">Nothing is read for this thread.</p>'); setHtml(e.body, `<p class="ws-line">${esc(why)}${p.draft ? ' Your unsent message is kept and comes back here when it reads again.' : ''}</p>`);
return; return;
} }
if (!p.chat) { p.chat = window.MosaicChat.pane(p.org); p.first = true; } if (!p.chat) { p.chat = window.MosaicChat.pane(p.org); p.first = true; }
const first = p.first; p.first = false; const first = p.first, chat = p.chat; p.first = false;
void p.chat.render({ path: `/chat/threads/${p.id}`, container: e.body, canSend: !!list && org?.busHealthy !== false && i?.chatSend === true, stopped: org?.busHealthy === false, navigated: first }); void chat.render({ path: `/chat/threads/${p.id}`, container: e.body, canSend: !!list && i?.chatSend === true, navigated: first }).then(() => {
const field = p.draft && p.chat === chat ? e.body.querySelector('form textarea') : null;
if (!field) return;
if (!field.value) { field.value = p.draft; field.dispatchEvent(new Event('input', { bubbles: true })); }
p.draft = '';
}, () => {});
} }
function paintTabs() { function paintTabs() {
const { root, panes, show } = mounted; const { root, panes, show } = mounted;
@@ -259,7 +273,7 @@
// ---------- panes ---------- // ---------- panes ----------
function makePane(key) { function makePane(key) {
const { org, id } = split(key); const { org, id } = split(key);
const p = { key, org, id, n: ++paneSeq, el: null, chat: null, first: true, title: null }; const p = { key, org, id, n: ++paneSeq, el: null, chat: null, first: true, title: null, draft: '' };
const box = mounted.root.querySelector('[data-assist-panes]'); const box = mounted.root.querySelector('[data-assist-panes]');
box.insertAdjacentHTML('beforeend', paneShell(p)); box.insertAdjacentHTML('beforeend', paneShell(p));
p.el = box.lastElementChild; p.el = box.lastElementChild;
@@ -418,7 +432,7 @@
infos.clear(); infos.clear();
void draw({ fresh: true }); void draw({ fresh: true });
} }
const hasDraft = () => !!mounted && mounted.panes.some(p => p.chat?.hasDraft()); const hasDraft = () => !!mounted && mounted.panes.some(p => !!p.draft.trim() || !!p.chat?.hasDraft());
// A write that changed an organization reads it again. // A write that changed an organization reads it again.
document.addEventListener('mosaic-org-changed', e => { if (mounted && mounted.panes.some(p => p.org === e.detail?.org)) { infos.delete(e.detail.org); void draw(); } }); document.addEventListener('mosaic-org-changed', e => { if (mounted && mounted.panes.some(p => p.org === e.detail?.org)) { infos.delete(e.detail.org); void draw(); } });
+1 -1
View File
@@ -279,7 +279,7 @@
const focused = $('sections').contains(active) || foot.contains(active) ? active.getAttribute('href') : null; const focused = $('sections').contains(active) || foot.contains(active) ? active.getAttribute('href') : null;
const icon = id => window.MosaicWorkspace?.icon?.(id) ?? `<svg class="i" aria-hidden="true"><use href="/icons.svg#i-${id}"/></svg>`; const icon = id => window.MosaicWorkspace?.icon?.(id) ?? `<svg class="i" aria-hidden="true"><use href="/icons.svg#i-${id}"/></svg>`;
const n = (count, what) => count > 0 ? `<span class="ws-n hot" aria-label="${count} ${what}">${count}</span>` : ''; const n = (count, what) => count > 0 ? `<span class="ws-n hot" aria-label="${count} ${what}">${count}</span>` : '';
const items = [['/dashboard', 'Dashboard', '', 'board'], ['/inbox', 'Inbox', n(inboxCount?.open, 'open for you'), 'inbox'], ['/tasks', 'Tasks', '', 'tasks'], ['/projects', 'Projects', '', 'folder'], ['/agents', 'Agents', '', 'agents'], ['/assistant', 'Assistant', n(messageCount, 'unread messages'), 'talk']]; const items = [['/dashboard', 'Dashboard', '', 'board'], ['/inbox', 'Inbox', n(inboxCount?.open, 'open for you'), 'inbox'], ['/tasks', 'Tasks', '', 'tasks'], ['/projects', 'Projects', '', 'folder'], ['/agents', 'Agents', '', 'agents'], ['/assistant', 'Assistant', '', 'talk']];
const link = ([h, l, extra, id]) => { const link = ([h, l, extra, id]) => {
// #1873 SP1: the Dashboard item is the system dashboard, `#/dashboard`, outside any organization. // #1873 SP1: the Dashboard item is the system dashboard, `#/dashboard`, outside any organization.
const cur = h === '/dashboard' ? (route === '/dashboard' || route === '/' || route === '/board') : route.startsWith(h) || (h === '/projects' && route.startsWith('/chat')) || (h === '/tasks' && route.startsWith('/trail/task')) || (h === '/inbox' && (route.startsWith('/trail/decision') || route.startsWith('/trail/message'))); const cur = h === '/dashboard' ? (route === '/dashboard' || route === '/' || route === '/board') : route.startsWith(h) || (h === '/projects' && route.startsWith('/chat')) || (h === '/tasks' && route.startsWith('/trail/task')) || (h === '/inbox' && (route.startsWith('/trail/decision') || route.startsWith('/trail/message')));
+3 -3
View File
@@ -657,7 +657,7 @@
} else { } else {
html += heading('Projects') + '<p>Projects and their conversations.</p><div class="chat-projects">' + c.projects.map(p => `<section class="panel"><h2>${link('projects', p.id, p.name)}</h2>${p.repo ? `<p class="muted">Repository: ${txt(p.repo)}</p>` : ''}${threads(c.threads.filter(t => t.project === p.id && !t.shared))}</section>`).join('') + (c.projects.length ? '' : c.readFailed ? '<p>Projects unavailable.</p>' : '<p>No projects yet.</p>') + '</div><h2>Shared with me</h2>' + threads(c.threads.filter(t => t.shared)) + creation('project', 'projects'); html += heading('Projects') + '<p>Projects and their conversations.</p><div class="chat-projects">' + c.projects.map(p => `<section class="panel"><h2>${link('projects', p.id, p.name)}</h2>${p.repo ? `<p class="muted">Repository: ${txt(p.repo)}</p>` : ''}${threads(c.threads.filter(t => t.project === p.id && !t.shared))}</section>`).join('') + (c.projects.length ? '' : c.readFailed ? '<p>Projects unavailable.</p>' : '<p>No projects yet.</p>') + '</div><h2>Shared with me</h2>' + threads(c.threads.filter(t => t.shared)) + creation('project', 'projects');
} }
if (!c.canSend && pane) html += c.stopped ? '<p class="muted">This organization\'s bus is stopped. Start it on Settings → Organizations to send messages here.</p>' : '<p class="muted">Sending is unavailable in this organization. This thread only reads.</p>'; if (!c.canSend && pane) html += '<p class="muted">Sending is unavailable in this organization. This thread only reads.</p>';
else if (!c.canSend) html += window.MosaicOrgBusStopped ? '<p class="muted">The bus is stopped. Use Start the bus on Settings → Organizations to send messages.</p>' : '<p class="muted">Creating and sending require signed-in send mode. This Console only reads.</p>'; else if (!c.canSend) html += window.MosaicOrgBusStopped ? '<p class="muted">The bus is stopped. Use Start the bus on Settings → Organizations to send messages.</p>' : '<p class="muted">Creating and sending require signed-in send mode. This Console only reads.</p>';
html += '</div>'; html += '</div>';
if (pane) html = html.replace(/\b(id|for|aria-controls|aria-describedby|aria-labelledby)="chat-/g, `$1="${env.prefix}`); if (pane) html = html.replace(/\b(id|for|aria-controls|aria-describedby|aria-labelledby)="chat-/g, `$1="${env.prefix}`);
@@ -768,7 +768,7 @@
err.code === 'thread-not-found' ? 'Thread not found.' : err.code === 'thread-not-found' ? 'Thread not found.' :
err.code === 'session-not-found' ? 'Session not found in this thread.' : 'Chat could not be read. Refresh to try again; your draft is kept.'; err.code === 'session-not-found' ? 'Session not found in this thread.' : 'Chat could not be read. Refresh to try again; your draft is kept.';
} }
async function render({ path, container, canSend, user = false, tester = false, navigated = false, next = false, stopped = false }) { async function render({ path, container, canSend, user = false, tester = false, navigated = false, next = false }) {
if (out()) return; if (out()) return;
const originalPath = path; const originalPath = path;
const sectionRoute = /^\/projects\/([0-9a-f-]+)\/(settings|conversations|tasks|requirements|files)$/i.exec(path); const sectionRoute = /^\/projects\/([0-9a-f-]+)\/(settings|conversations|tasks|requirements|files)$/i.exec(path);
@@ -789,7 +789,7 @@
paint(); return; paint(); return;
} }
const old = current; const old = current;
current = { container, kind, id, stopped, orgArchived: old?.orgArchived ?? false, loading: true, archiveUnknown: kind === 'thread' && !user && !tester, settings: !!settings, section, path: originalPath, canSend, user, tester, chatGrants: [], thread: old && old.id === id ? old.thread : null, shared: old && old.id === id ? old.shared : false, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], instances: old?.instances ?? [], instanceError: kind === 'project' ? 'Reading thread instances…' : '', error: '', readFailed: false, channelDisabled: old?.channelDisabled ?? false, refused: false, legacyDefault: old && old.id === id ? old.legacyDefault : false, sessions: old && old.id === id ? old.sessions ?? [] : [], session: match[3] ?? (old && old.id === id ? old.session : null), key: match[3] ? id + '/' + match[3] : old && old.id === id ? old.key : id }; current = { container, kind, id, orgArchived: old?.orgArchived ?? false, loading: true, archiveUnknown: kind === 'thread' && !user && !tester, settings: !!settings, section, path: originalPath, canSend, user, tester, chatGrants: [], thread: old && old.id === id ? old.thread : null, shared: old && old.id === id ? old.shared : false, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], instances: old?.instances ?? [], instanceError: kind === 'project' ? 'Reading thread instances…' : '', error: '', readFailed: false, channelDisabled: old?.channelDisabled ?? false, refused: false, legacyDefault: old && old.id === id ? old.legacyDefault : false, sessions: old && old.id === id ? old.sessions ?? [] : [], session: match[3] ?? (old && old.id === id ? old.session : null), key: match[3] ? id + '/' + match[3] : old && old.id === id ? old.key : id };
current.projectArchived = current.projects.some(p => p.id === (kind === 'project' ? id : current.thread?.project) && p.archived); current.projectArchived = current.projects.some(p => p.id === (kind === 'project' ? id : current.thread?.project) && p.archived);
if (kind === 'thread' && old?.kind === 'thread' && old.id === id && !navigated && old.archiveUnknown === false && old.projects.some(p => p.id === old.thread?.project)) current.archiveUnknown = false; if (kind === 'thread' && old?.kind === 'thread' && old.id === id && !navigated && old.archiveUnknown === false && old.projects.some(p => p.id === old.thread?.project)) current.archiveUnknown = false;
if (current.projectArchived) current.archiveUnknown = false; if (current.projectArchived) current.archiveUnknown = false;
+1 -1
View File
@@ -21,7 +21,7 @@
.then(r => (r.type === 'opaqueredirect' ? null : r.ok ? r.json() : null)); .then(r => (r.type === 'opaqueredirect' ? null : r.ok ? r.json() : null));
const ready = readConfig().then(c => { config = c; boot = typeof c?.boot === 'string' ? c.boot : null; return c; }).catch(() => null); const ready = readConfig().then(c => { config = c; boot = typeof c?.boot === 'string' ? c.boot : null; return c; }).catch(() => null);
const dirty = () => !!(window.MosaicChat?.hasDraft?.() || window.MosaicOrgCreate?.hasDraft?.() const dirty = () => !!(window.MosaicChat?.hasDraft?.() || window.MosaicAssistant?.hasDraft?.() || window.MosaicOrgCreate?.hasDraft?.()
|| document.querySelector('.settings-dot') || document.querySelector('.settings-dot')
|| [...document.querySelectorAll('#reply, #conv-reply, #doc-source, #send-body')].some(el => !!el.value?.trim())); || [...document.querySelectorAll('#reply, #conv-reply, #doc-source, #send-body')].some(el => !!el.value?.trim()));
+1 -1
View File
@@ -118,7 +118,7 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date(
return read; return read;
}; };
let previous = location.hash, skipGuard = false; let previous = location.hash, skipGuard = false;
const dirty = () => window.MosaicChat?.hasDraft?.() || window.MosaicOrgCreate?.hasDraft?.() || [...document.querySelectorAll('#reply, #conv-reply, #doc-source')].some(el => !!el.value?.trim()); const dirty = () => window.MosaicChat?.hasDraft?.() || window.MosaicAssistant?.hasDraft?.() || window.MosaicOrgCreate?.hasDraft?.() || [...document.querySelectorAll('#reply, #conv-reply, #doc-source')].some(el => !!el.value?.trim());
// A switch scopes later reads to the new organization; listeners drop what they held for the old one. // A switch scopes later reads to the new organization; listeners drop what they held for the old one.
function rescope(seg) { function rescope(seg) {
const from = current; const from = current;
@@ -52,6 +52,7 @@ function orgs(state) {
messages: async ({ thread }) => [ messages: async ({ thread }) => [
...(MESSAGES[thread] ? [{ id: thread.replace(/^2/, '3'), from_role: 'assistant', body: MESSAGES[thread], at: '2026-11-15T10:00:00.000Z' }] : []), ...(MESSAGES[thread] ? [{ id: thread.replace(/^2/, '3'), from_role: 'assistant', body: MESSAGES[thread], at: '2026-11-15T10:00:00.000Z' }] : []),
...state.sent.filter(s => s.org === id && s.thread === thread).map(s => ({ id: s.id, from_role: 'human', body: s.body, at: '2026-11-15T10:01:00.000Z' })), ...state.sent.filter(s => s.org === id && s.thread === thread).map(s => ({ id: s.id, from_role: 'human', body: s.body, at: '2026-11-15T10:01:00.000Z' })),
...(state.replies || []).filter(r => r.org === id && r.thread === thread).map((r, n) => ({ id: `50000000-0000-4000-8000-${String(n + 1).padStart(12, '0')}`, from_role: 'assistant', body: r.body, at: '2026-11-15T10:02:00.000Z' })),
], ],
send: async ({ thread, body }) => { send: async ({ thread, body }) => {
const sent = { org: id, thread, body, id: `40000000-0000-4000-8000-${String(state.sent.length + 1).padStart(12, '0')}` }; const sent = { org: id, thread, body, id: `40000000-0000-4000-8000-${String(state.sent.length + 1).padStart(12, '0')}` };
@@ -76,7 +77,7 @@ async function open(t, { width = 1440, height = 900, hash, state = { down: new S
await b.viewport(width, height); await b.viewport(width, height);
const base = `http://127.0.0.1:${server.address().port}/`; const base = `http://127.0.0.1:${server.address().port}/`;
await b.navigate(base + hash); await b.navigate(base + hash);
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`); const wait = (expression, tries = 200) => b.evaluate(`(async()=>{for(let i=0;i<${tries};i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
return { b, base, wait, state }; return { b, base, wait, state };
} }
@@ -85,6 +86,7 @@ const panes = `[...document.querySelectorAll('[data-assist-pane]')].map(p => p.d
const shown = `[...document.querySelectorAll('[data-assist-pane]')].filter(p => p.getClientRects().length).map(p => p.dataset.assistPane)`; const shown = `[...document.querySelectorAll('[data-assist-pane]')].filter(p => p.getClientRects().length).map(p => p.dataset.assistPane)`;
const query = `new URLSearchParams(location.hash.split('?')[1] || '').get('open')`; const query = `new URLSearchParams(location.hash.split('?')[1] || '').get('open')`;
const ready = sel => `!!${PANE(sel)}?.querySelector('.chat-log li') && !!${PANE(sel)}?.querySelector('[data-ws-orgchip]')`; const ready = sel => `!!${PANE(sel)}?.querySelector('.chat-log li') && !!${PANE(sel)}?.querySelector('[data-ws-orgchip]')`;
const SENT = '/Accepted message|Message shown in this conversation/';
const pick = (b, value) => b.evaluate(`(() => { const s = document.querySelector('[data-assist-open]'); s.value = ${JSON.stringify(value)}; if (s.value !== ${JSON.stringify(value)}) throw Error('no option ' + ${JSON.stringify(value)}); s.dispatchEvent(new Event('change', { bubbles: true })); })()`); const pick = (b, value) => b.evaluate(`(() => { const s = document.querySelector('[data-assist-open]'); s.value = ${JSON.stringify(value)}; if (s.value !== ${JSON.stringify(value)}) throw Error('no option ' + ${JSON.stringify(value)}); s.dispatchEvent(new Event('change', { bubbles: true })); })()`);
test('the sidebar Assistant opens one system page, and two threads from two organizations open side by side with their chips', async t => { test('the sidebar Assistant opens one system page, and two threads from two organizations open side by side with their chips', async t => {
@@ -148,14 +150,14 @@ test('a send in organization B\'s pane reaches B\'s thread while organization A
const field = `${PANE(`beta:${T.b1}`)}.querySelector('form textarea')`; const field = `${PANE(`beta:${T.b1}`)}.querySelector('form textarea')`;
await wait(`!${field}.disabled`); await wait(`!${field}.disabled`);
await b.evaluate(`(() => { const el = ${field}; el.value = 'rotate the beta key'; el.dispatchEvent(new Event('input', { bubbles: true })); el.form.requestSubmit(); })()`); await b.evaluate(`(() => { const el = ${field}; el.value = 'rotate the beta key'; el.dispatchEvent(new Event('input', { bubbles: true })); el.form.requestSubmit(); })()`);
await wait(`/Accepted message/.test(${PANE(`beta:${T.b1}`)}.innerText)`); await wait(`${SENT}.test(${PANE(`beta:${T.b1}`)}.innerText)`);
assert.deepEqual(state.sent.map(({ org, thread, body }) => ({ org, thread, body })), [{ org: 'beta', thread: T.b1, body: 'rotate the beta key' }]); assert.deepEqual(state.sent.map(({ org, thread, body }) => ({ org, thread, body })), [{ org: 'beta', thread: T.b1, body: 'rotate the beta key' }]);
assert.doesNotMatch(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.innerText`), /Accepted message/, 'the other pane shows no receipt'); assert.doesNotMatch(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.innerText`), /Accepted message|Message shown in this conversation/, 'the other pane shows no receipt');
assert.equal(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.querySelector('form textarea').value`), '', 'the other pane keeps its own draft'); assert.equal(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.querySelector('form textarea').value`), '', 'the other pane keeps its own draft');
// A send in A's pane reaches A. // A send in A's pane reaches A.
const other = `${PANE(`alpha:${T.a1}`)}.querySelector('form textarea')`; const other = `${PANE(`alpha:${T.a1}`)}.querySelector('form textarea')`;
await b.evaluate(`(() => { const el = ${other}; el.value = 'alpha copy'; el.dispatchEvent(new Event('input', { bubbles: true })); el.form.requestSubmit(); })()`); await b.evaluate(`(() => { const el = ${other}; el.value = 'alpha copy'; el.dispatchEvent(new Event('input', { bubbles: true })); el.form.requestSubmit(); })()`);
await wait(`/Accepted message/.test(${PANE(`alpha:${T.a1}`)}.innerText)`); await wait(`${SENT}.test(${PANE(`alpha:${T.a1}`)}.innerText)`);
assert.deepEqual(state.sent.map(({ org, thread }) => `${org}:${thread}`), [`beta:${T.b1}`, `alpha:${T.a1}`]); assert.deepEqual(state.sent.map(({ org, thread }) => `${org}:${thread}`), [`beta:${T.b1}`, `alpha:${T.a1}`]);
// Live updates: a refresh shows the sent message in its own pane only. // Live updates: a refresh shows the sent message in its own pane only.
await b.evaluate('window.MosaicAssistant.refresh()'); await b.evaluate('window.MosaicAssistant.refresh()');
@@ -185,18 +187,41 @@ test('a stopped organization is named at the top, its pane says so, and the othe
await wait(`/bus is stopped/i.test(${PANE(`home:${T.h1}`)}?.querySelector('[data-assist-line]')?.textContent || '')`); await wait(`/bus is stopped/i.test(${PANE(`home:${T.h1}`)}?.querySelector('[data-assist-line]')?.textContent || '')`);
assert.equal(await b.evaluate(`${PANE(`home:${T.h1}`)}.querySelector('[data-ws-orgchip]').textContent`), 'Home'); assert.equal(await b.evaluate(`${PANE(`home:${T.h1}`)}.querySelector('[data-ws-orgchip]').textContent`), 'Home');
assert.equal(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.querySelector('form textarea').disabled`), false); assert.equal(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.querySelector('form textarea').disabled`), false);
// Beta's bus stops while its pane is open: that pane says so; Alpha's still sends. // Beta's bus stops while its pane is open, with an unsent message in it: that pane says so and
// only so, and holds the message; Alpha's still sends.
const betaField = `${PANE(`beta:${T.b1}`)}.querySelector('form textarea')`;
await wait(`!!${betaField} && !${betaField}.disabled`);
await b.evaluate(`(() => { const el = ${betaField}; el.value = 'beta draft'; el.dispatchEvent(new Event('input', { bubbles: true })); })()`);
state.down.add('beta'); state.down.add('beta');
await b.evaluate('window.MosaicAssistant.refresh()'); await b.evaluate('window.MosaicAssistant.refresh()');
await wait(`/bus is stopped/i.test(${PANE(`beta:${T.b1}`)}.querySelector('[data-assist-line]')?.textContent || '') && /Beta/.test(document.querySelector('#bus-view [data-ws-unread]')?.textContent || '')`); await wait(`/bus is stopped/i.test(${PANE(`beta:${T.b1}`)}.querySelector('[data-assist-line]')?.textContent || '') && /Beta/.test(document.querySelector('#bus-view [data-ws-unread]')?.textContent || '')`);
await wait(`!${PANE(`beta:${T.b1}`)}.querySelector('form textarea') || ${PANE(`beta:${T.b1}`)}.querySelector('form textarea').disabled`); await wait(`!${betaField}`);
const betaBody = await b.evaluate(`${PANE(`beta:${T.b1}`)}.querySelector('.assist-body').innerText`);
assert.match(betaBody, /Nothing is read for this thread while Beta's bus is stopped\. Your unsent message is kept/);
assert.doesNotMatch(betaBody, /Refresh to try again|Read again|Conversation unavailable/, 'no line that says a refresh would help');
assert.equal(await b.evaluate('window.MosaicAssistant.hasDraft()'), true, 'the held message counts as a draft');
const field = `${PANE(`alpha:${T.a1}`)}.querySelector('form textarea')`; const field = `${PANE(`alpha:${T.a1}`)}.querySelector('form textarea')`;
assert.equal(await b.evaluate(`${field}.disabled`), false); assert.equal(await b.evaluate(`${field}.disabled`), false);
await b.evaluate(`(() => { const el = ${field}; el.value = 'still here'; el.dispatchEvent(new Event('input', { bubbles: true })); el.form.requestSubmit(); })()`); await b.evaluate(`(() => { const el = ${field}; el.value = 'still here'; el.dispatchEvent(new Event('input', { bubbles: true })); el.form.requestSubmit(); })()`);
await wait(`/Accepted message/.test(${PANE(`alpha:${T.a1}`)}.innerText)`); await wait(`${SENT}.test(${PANE(`alpha:${T.a1}`)}.innerText)`);
assert.deepEqual(state.sent.map(({ org, body }) => `${org}:${body}`), ['alpha:still here']); assert.deepEqual(state.sent.map(({ org, body }) => `${org}:${body}`), ['alpha:still here']);
// The picker names the stopped organizations and offers none of their threads. // The picker names the stopped organizations and offers none of their threads.
assert.match(await b.evaluate(`[...document.querySelectorAll('[data-assist-open] optgroup')].map(g => g.label).join('|')`), /Home · bus stopped/); assert.match(await b.evaluate(`[...document.querySelectorAll('[data-assist-open] optgroup')].map(g => g.label).join('|')`), /Home · bus stopped/);
// Beta's bus starts again: its pane reads again and the held message is back in its composer.
state.down.delete('beta');
await b.evaluate('window.MosaicAssistant.refresh()');
await wait(`${ready(`beta:${T.b1}`)} && ${betaField}?.value === 'beta draft'`);
assert.deepEqual(state.sent.map(({ org, body }) => `${org}:${body}`), ['alpha:still here'], 'the held message is not sent by itself');
});
test('an open pane shows a new reply on its own, with no refresh', async t => {
const state = { down: new Set(['home']), sent: [], created: [], replies: [] };
const { b, wait } = await open(t, { hash: `#/assistant?open=alpha:${T.a1},beta:${T.b1}`, state });
await wait(`${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)}`);
state.replies.push({ org: 'beta', thread: T.b1, body: 'Rotation finished.' });
// The page polls every 10 s; wait up to 15 s without calling refresh.
await wait(`/Rotation finished\./.test(${PANE(`beta:${T.b1}`)}.querySelector('.chat-log').innerText)`, 500);
assert.doesNotMatch(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.querySelector('.chat-log').innerText`), /Rotation finished/);
}); });
test('the old Side by side address lands on the Assistant page', async t => { test('the old Side by side address lands on the Assistant page', async t => {
@@ -211,7 +236,7 @@ test('the old Side by side address lands on the Assistant page', async t => {
}); });
test('at 390 one pane shows at a time with a tab strip to switch', async t => { test('at 390 one pane shows at a time with a tab strip to switch', async t => {
const { b, wait } = await open(t, { width: 390, height: 844, hash: `#/assistant?open=alpha:${T.a1},beta:${T.b1}` }); const { b, base, wait } = await open(t, { width: 390, height: 844, hash: `#/assistant?open=alpha:${T.a1},beta:${T.b1}` });
await wait(`${ready(`alpha:${T.a1}`)} && document.querySelectorAll('[data-assist-tabs] [data-assist-show]').length === 2`); await wait(`${ready(`alpha:${T.a1}`)} && document.querySelectorAll('[data-assist-tabs] [data-assist-show]').length === 2`);
assert.deepEqual(await b.evaluate(shown), [`alpha:${T.a1}`]); assert.deepEqual(await b.evaluate(shown), [`alpha:${T.a1}`]);
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-assist-tabs] [data-assist-show]')].map(x => [x.textContent.trim(), x.getAttribute('aria-pressed')])`), [['Launch email', 'true'], ['Gateway auth', 'false']]); assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-assist-tabs] [data-assist-show]')].map(x => [x.textContent.trim(), x.getAttribute('aria-pressed')])`), [['Launch email', 'true'], ['Gateway auth', 'false']]);
@@ -219,7 +244,11 @@ test('at 390 one pane shows at a time with a tab strip to switch', async t => {
await wait(`${ready(`beta:${T.b1}`)}`); await wait(`${ready(`beta:${T.b1}`)}`);
assert.deepEqual(await b.evaluate(shown), [`beta:${T.b1}`]); assert.deepEqual(await b.evaluate(shown), [`beta:${T.b1}`]);
assert.equal(await b.evaluate(`new URLSearchParams(location.hash.split('?')[1]).get('show')`), `beta:${T.b1}`); assert.equal(await b.evaluate(`new URLSearchParams(location.hash.split('?')[1]).get('show')`), `beta:${T.b1}`);
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), true, 'no horizontal page scroll'); assert.equal(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), true, 'no horizontal page scroll'); // A pasted address names the shown pane however its id is cased.
await b.navigate('about:blank');
await b.navigate(base + `#/assistant?open=alpha:${T.a1},beta:${T.b1}&show=beta:${T.b1.toUpperCase()}`);
await wait(`${ready(`beta:${T.b1}`)}`);
assert.deepEqual(await b.evaluate(shown), [`beta:${T.b1}`]);
}); });
test('"Open thread" starts a new thread in a chosen organization through that organization\'s own route', async t => { test('"Open thread" starts a new thread in a chosen organization through that organization\'s own route', async t => {