fix(webui): sp6 re-check notes, a held message is never overwritten or held out of sight, and a pane whose organization left the list does not promise it comes back (#1873, lane sp6-threads)

The held draft goes back only into an empty composer and is cleared only once it is in it. A thread
that reads without a composer shows the held message in the pane's line. Only a stopped or
unavailable bus says the message comes back when the pane reads again.
This commit is contained in:
jason.woltje committed 2026-11-15 04:57:37 -06:00
1 parent b982404963
commit 02df2a3a25
2 files changed
+33 -8

No files matched your search

+14 -6
View File
@@ -214,14 +214,14 @@
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, why = 'Nothing is read for this thread.'; let line = '', readable = true, why = 'Nothing is read for this thread.', back = false;
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) { else if (org.busHealthy === false) {
const how = org.busStopped ? 'stopped' : 'unavailable'; 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.`; 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; why = `Nothing is read for this thread while ${name}'s bus is ${how}.`; readable = false; back = true;
} }
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;
@@ -231,16 +231,24 @@
const typed = p.chat && e.body.querySelector('form textarea')?.value; const typed = p.chat && e.body.querySelector('form textarea')?.value;
if (typed?.trim()) p.draft = typed; 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">${esc(why)}${p.draft ? ' Your unsent message is kept and comes back here when it reads again.' : ''}</p>`); const kept = !p.draft ? '' : back ? ' Your unsent message is kept and comes back here when it reads again.' : ' Your unsent message is kept while this pane stays open.';
setHtml(e.body, `<p class="ws-line">${esc(why)}${kept}</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, chat = p.chat; p.first = false; const first = p.first, chat = p.chat; p.first = false;
void chat.render({ path: `/chat/threads/${p.id}`, container: e.body, canSend: !!list && i?.chatSend === true, navigated: first }).then(() => { 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 (!p.draft || p.chat !== chat || !p.el.isConnected) return;
if (!field) return; const field = e.body.querySelector('form textarea');
if (!field) {
// The thread reads but offers no composer: the held message stays visible here, never silently held.
const held = p.draft.length > 200 ? `${p.draft.slice(0, 200)}…` : p.draft;
setText(e.line, `${line ? `${line} ` : ''}Your unsent message is kept until this thread can send: “${held}”`); e.line.hidden = false;
return;
}
// Put the held message back only into an empty composer; text typed meanwhile is never overwritten.
if (!field.value) { field.value = p.draft; field.dispatchEvent(new Event('input', { bubbles: true })); } if (!field.value) { field.value = p.draft; field.dispatchEvent(new Event('input', { bubbles: true })); }
p.draft = ''; if (field.value === p.draft) p.draft = '';
}, () => {}); }, () => {});
} }
function paintTabs() { function paintTabs() {
@@ -31,9 +31,9 @@ function orgs(state) {
const MESSAGES = { [T.a1]: 'Alpha draft is ready.', [T.a2]: 'Kickoff agenda.', [T.b1]: 'Beta tokens rotate nightly.', [T.h1]: 'Seeds arrive Monday.' }; const MESSAGES = { [T.a1]: 'Alpha draft is ready.', [T.a2]: 'Kickoff agenda.', [T.b1]: 'Beta tokens rotate nightly.', [T.h1]: 'Seeds arrive Monday.' };
const down = () => { throw Object.assign(new Error('bus down'), { code: 'bus-unavailable' }); }; const down = () => { throw Object.assign(new Error('bus down'), { code: 'bus-unavailable' }); };
return { return {
list: async () => ORGS.map(o => ({ ...o, busHealthy: !state.down.has(o.id), ...(state.down.has(o.id) ? { busStopped: true } : {}) })), list: async () => ORGS.filter(o => !state.gone?.has(o.id)).map(o => ({ ...o, busHealthy: !state.down.has(o.id), ...(state.down.has(o.id) ? { busStopped: true } : {}) })),
resolve: async id => { resolve: async id => {
const org = ORGS.find(o => o.id === id); if (!org) return null; const org = ORGS.find(o => o.id === id); if (!org || state.gone?.has(id)) return null;
if (state.down.has(id)) return { busHealthy: false, reads: null, bus: {}, chat: { projects: down, threads: down, sessions: down, shares: down, messages: down } }; if (state.down.has(id)) return { busHealthy: false, reads: null, bus: {}, chat: { projects: down, threads: down, sessions: down, shares: down, messages: down } };
const threads = () => [...THREADS[id], ...state.created.filter(c => c.org === id).map(c => ({ id: c.id, project: c.project, name: c.name, instance: c.instance }))]; const threads = () => [...THREADS[id], ...state.created.filter(c => c.org === id).map(c => ({ id: c.id, project: c.project, name: c.name, instance: c.instance }))];
return { return {
@@ -214,6 +214,23 @@ test('a stopped organization is named at the top, its pane says so, and the othe
assert.deepEqual(state.sent.map(({ org, body }) => `${org}:${body}`), ['alpha:still here'], 'the held message is not sent by itself'); assert.deepEqual(state.sent.map(({ org, body }) => `${org}:${body}`), ['alpha:still here'], 'the held message is not sent by itself');
}); });
test('a pane whose organization leaves the list keeps its unsent message and does not promise it comes back', async t => {
const state = { down: new Set(['home']), gone: new Set(), sent: [], created: [] };
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}`)}`);
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.gone.add('beta');
await b.evaluate('window.MosaicAssistant.refresh()');
await wait(`/not on your organization list/.test(${PANE(`beta:${T.b1}`)}.querySelector('[data-assist-line]')?.textContent || '') && !${betaField}`);
const body = await b.evaluate(`${PANE(`beta:${T.b1}`)}.querySelector('.assist-body').innerText`);
assert.match(body, /Your unsent message is kept while this pane stays open\./);
assert.doesNotMatch(body, /comes back/, 'no promise that an organization off the list reads again');
assert.equal(await b.evaluate('window.MosaicAssistant.hasDraft()'), true);
assert.deepEqual(state.sent, []);
});
test('an open pane shows a new reply on its own, with no refresh', async t => { 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 state = { down: new Set(['home']), sent: [], created: [], replies: [] };
const { b, wait } = await open(t, { hash: `#/assistant?open=alpha:${T.a1},beta:${T.b1}`, state }); const { b, wait } = await open(t, { hash: `#/assistant?open=alpha:${T.a1},beta:${T.b1}`, state });