diff --git a/packages/webui/src/public/assistant.js b/packages/webui/src/public/assistant.js index a398b8985..517c3e463 100644 --- a/packages/webui/src/public/assistant.js +++ b/packages/webui/src/public/assistant.js @@ -224,8 +224,16 @@ 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; + // A held message the last render had no composer for stays in the line between polls, so the + // status line neither flickers nor is announced again on every read. + const withHeld = () => { + const cps = [...p.draft.replace(/\s+/g, ' ').trim()]; + return `${line ? `${line} ` : ''}Your unsent message is kept until this thread can send: “${cps.length > 200 ? `${cps.slice(0, 200).join('')}…` : cps.join('')}”`; + }; + const shown = readable && p.held && p.draft ? withHeld() : line; + setText(e.line, shown); e.line.hidden = !shown; if (!readable) { + p.held = false; // 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; @@ -236,19 +244,21 @@ return; } 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, seq = ++p.seq; p.first = false; void chat.render({ path: `/chat/threads/${p.id}`, container: e.body, canSend: !!list && i?.chatSend === true, navigated: first }).then(() => { - if (!p.draft || p.chat !== chat || !p.el.isConnected) return; - const field = e.body.querySelector('form textarea'); - if (!field) { + // Only the latest render of this pane decides: an earlier one that a newer render cut short + // has no composer yet and must not claim the thread cannot send. + if (seq !== p.seq || p.chat !== chat || !p.el.isConnected) return; + const field = p.draft ? e.body.querySelector('form textarea') : null; + if (p.draft && !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; + p.held = true; const text = withHeld(); setText(e.line, text); 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 === p.draft) p.draft = ''; + if (field && !field.value) { field.value = p.draft; field.dispatchEvent(new Event('input', { bubbles: true })); } + if (field && field.value === p.draft) p.draft = ''; + if (p.held && (field || !p.draft)) { p.held = false; setText(e.line, line); e.line.hidden = !line; } }, () => {}); } function paintTabs() { @@ -281,7 +291,7 @@ // ---------- panes ---------- function makePane(key) { const { org, id } = split(key); - const p = { key, org, id, n: ++paneSeq, el: null, chat: null, first: true, title: null, draft: '' }; + const p = { key, org, id, n: ++paneSeq, el: null, chat: null, first: true, title: null, draft: '', held: false, seq: 0 }; const box = mounted.root.querySelector('[data-assist-panes]'); box.insertAdjacentHTML('beforeend', paneShell(p)); p.el = box.lastElementChild;