diff --git a/packages/webui/src/public/assistant.js b/packages/webui/src/public/assistant.js index ea3aba28d..a398b8985 100644 --- a/packages/webui/src/public/assistant.js +++ b/packages/webui/src/public/assistant.js @@ -214,14 +214,14 @@ 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; 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.'; 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.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; + 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.`; setText(e.line, line); e.line.hidden = !line; @@ -231,16 +231,24 @@ const typed = p.chat && e.body.querySelector('form textarea')?.value; if (typed?.trim()) p.draft = typed; p.chat?.dispose(); p.chat = null; - setHtml(e.body, `

${esc(why)}${p.draft ? ' Your unsent message is kept and comes back here when it reads again.' : ''}

`); + 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, `

${esc(why)}${kept}

`); return; } if (!p.chat) { p.chat = window.MosaicChat.pane(p.org); p.first = true; } 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(() => { - const field = p.draft && p.chat === chat ? e.body.querySelector('form textarea') : null; - if (!field) return; + if (!p.draft || p.chat !== chat || !p.el.isConnected) 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 })); } - p.draft = ''; + if (field.value === p.draft) p.draft = ''; }, () => {}); } function paintTabs() { diff --git a/packages/webui/tests/assistant-threads-browser.test.mjs b/packages/webui/tests/assistant-threads-browser.test.mjs index 9644010fd..ce3f388ab 100644 --- a/packages/webui/tests/assistant-threads-browser.test.mjs +++ b/packages/webui/tests/assistant-threads-browser.test.mjs @@ -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 down = () => { throw Object.assign(new Error('bus down'), { code: 'bus-unavailable' }); }; 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 => { - 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 } }; 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 { @@ -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'); }); +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 => { 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 });