From 9cff3255e6586973734330339991ef01b9976ec2 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Nov 2026 09:23:20 -0600 Subject: [PATCH] fix(webui): sp11 items 4 and 5, a pane's unsent message is kept per thread for the tab's life and comes back when the pane opens again, Full view included; closing a pane with one asks first; a pane whose send waits for its receipt is held when a fourth thread opens (#1873, lane sp11-fixes) --- packages/webui/src/public/assistant.js | 32 +++++++-- packages/webui/src/public/chat.js | 8 ++- packages/webui/src/public/display.js | 3 +- .../tests/assistant-threads-browser.test.mjs | 68 ++++++++++++++++++- 4 files changed, 100 insertions(+), 11 deletions(-) diff --git a/packages/webui/src/public/assistant.js b/packages/webui/src/public/assistant.js index 27abd59de..aa9786502 100644 --- a/packages/webui/src/public/assistant.js +++ b/packages/webui/src/public/assistant.js @@ -299,9 +299,24 @@ } // ---------- panes ---------- + // SP11: a pane's unsent message is kept per thread for the life of the browser tab, so leaving the + // Assistant page (a Full view link included) and coming back puts it in the composer again. + // sessionStorage can be missing or refuse a write; the Map still keeps it while the page lives. + const DRAFTS = 'mosaic-assistant-drafts'; + const kept = (() => { + try { + const v = JSON.parse(sessionStorage.getItem(DRAFTS) || 'null'); + return new Map(v && typeof v === 'object' && !Array.isArray(v) ? Object.entries(v).filter(([k, t]) => typeof t === 'string' && t.trim() && parse(`open=${k}`).keys[0] === k) : []); + } catch { return new Map(); } + })(); + const saveKept = () => { try { if (kept.size) sessionStorage.setItem(DRAFTS, JSON.stringify(Object.fromEntries(kept))); else sessionStorage.removeItem(DRAFTS); } catch { /* the Map still holds it */ } }; + const typedIn = p => p.chat?.draft?.() || p.draft || p.el?.querySelector('form textarea')?.value || ''; + function keep(p) { const text = typedIn(p); if (text.trim()) kept.set(p.key, text); else kept.delete(p.key); saveKept(); } + function drop(p) { if (kept.delete(p.key)) saveKept(); } + window.addEventListener('pagehide', () => { if (mounted) for (const p of mounted.panes) keep(p); }); function makePane(key) { const { org, id } = split(key); - const p = { key, org, id, n: ++paneSeq, el: null, chat: null, first: true, title: null, draft: '', held: false, seq: 0 }; + const p = { key, org, id, n: ++paneSeq, el: null, chat: null, first: true, title: null, draft: kept.get(key) || '', held: false, seq: 0 }; const box = mounted.root.querySelector('[data-assist-panes]'); box.insertAdjacentHTML('beforeend', paneShell(p)); p.el = box.lastElementChild; @@ -320,7 +335,9 @@ } // SP7: a thread opened from a link takes a free slot or, with all three taken, the place of the // first pane that holds no unsent message. When every pane holds one, nothing is replaced. - const holds = p => !!p.draft.trim() || !!p.chat?.hasDraft(); + // SP11: a pane whose send is in flight, or whose sent message the conversation does not show yet, + // is held too, until its receipt is in. + const holds = p => !!p.draft.trim() || !!p.chat?.hasDraft() || !!p.chat?.busy?.(); function place(key) { const had = mounted.panes.find(p => p.key === key); if (had) return had; @@ -328,11 +345,11 @@ const old = mounted.panes.find(p => !holds(p)); if (!old) return null; const p = makePane(key); - old.chat?.dispose(); old.chat = null; old.el.replaceWith(p.el); + drop(old); old.chat?.dispose(); old.chat = null; old.el.replaceWith(p.el); mounted.panes[mounted.panes.indexOf(old)] = p; return p; } - const REFUSED = `That thread was not opened: all ${MAX} panes hold an unsent message. Send or clear one, or close a pane, then open the thread again.`; + const REFUSED = `That thread was not opened: all ${MAX} panes hold an unsent message or a message waiting to show. Send or clear one, wait for a sent message to show, or close a pane, then open the thread again.`; function add(key) { if (!mounted) return false; const p = place(key); @@ -360,10 +377,13 @@ else location.hash = `#/assistant?add=${key}`; }); + // SP11: closing a pane that holds unsent text asks first; a close drops the kept text. function closePane(p) { if (!mounted || !p) return; const at = mounted.panes.indexOf(p); if (at < 0) return; + if (typedIn(p).trim() && !window.confirm(`Close ${p.title || 'this thread'}? Its unsent message is lost.`)) return; + drop(p); mounted.notice = ''; p.chat?.dispose(); p.chat = null; p.el.remove(); mounted.panes.splice(at, 1); @@ -484,7 +504,7 @@ function unmount() { if (!mounted) return; clearTimeout(mounted.timer); - for (const p of mounted.panes) p.chat?.dispose(); + for (const p of mounted.panes) { keep(p); p.chat?.dispose(); } window.removeEventListener('resize', fit); mounted = null; mountGen++; } @@ -495,7 +515,7 @@ if (typeof query === 'string') { const want = parse(query); if (want.named && want.keys.join(',') !== mounted.panes.map(p => p.key).join(',')) { - for (const p of [...mounted.panes]) if (!want.keys.includes(p.key)) { p.chat?.dispose(); p.el.remove(); } + for (const p of [...mounted.panes]) if (!want.keys.includes(p.key)) { keep(p); p.chat?.dispose(); p.el.remove(); } const kept = new Map(mounted.panes.map(p => [p.key, p])); mounted.panes = want.keys.map(k => kept.get(k) || makePane(k)); const box = mounted.root.querySelector('[data-assist-panes]'); diff --git a/packages/webui/src/public/chat.js b/packages/webui/src/public/chat.js index e5d0c284a..5faf149cf 100644 --- a/packages/webui/src/public/chat.js +++ b/packages/webui/src/public/chat.js @@ -1290,12 +1290,16 @@ } on('mosaic-signed-out', () => { signedOut = true; forget(); }); const hasDraft = () => [...states.values()].some(s => Boolean(s.draft?.trim())) || [...forms.values()].some(f => Boolean(f.name?.trim() || f.repo || f.instanceChosen)); + // SP11: the unsent text of a pane's thread, and whether a write is in flight or a sent message + // still waits for the conversation to show it. + const draft = () => [...states.values()].find(s => s.draft?.trim())?.draft || ''; + const busy = () => [...states.values()].some(s => s.pending || !!s.receiptId); // Ends a pane instance: its listeners, reads and observers stop and its container is left as is. function dispose() { life.abort(); generation++; controller?.abort(); transcriptObserver?.disconnect(); transcriptResize?.disconnect(); current = null; lastPaint = null; } - return { render, leave, forget, refresh, projectPath, hasDraft, dispose }; + return { render, leave, forget, refresh, projectPath, hasDraft, draft, busy, dispose }; } const main = chatInstance(); const ORG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; @@ -1304,7 +1308,7 @@ function pane(org) { if (typeof org !== 'string' || !ORG.test(org)) throw new TypeError('pane needs an organization id'); const chat = chatInstance({ org, prefix: `chat-p${++panes}-` }); - return Object.freeze({ render: chat.render, refresh: chat.refresh, dispose: chat.dispose, hasDraft: chat.hasDraft }); + return Object.freeze({ render: chat.render, refresh: chat.refresh, dispose: chat.dispose, hasDraft: chat.hasDraft, draft: chat.draft, busy: chat.busy }); } window.MosaicChat = Object.freeze({ render: main.render, leave: main.leave, forget: main.forget, projectPath: main.projectPath, hasDraft: main.hasDraft, pane }); })(); diff --git a/packages/webui/src/public/display.js b/packages/webui/src/public/display.js index 7a9978fae..df2ed3ed9 100644 --- a/packages/webui/src/public/display.js +++ b/packages/webui/src/public/display.js @@ -118,7 +118,8 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date( return read; }; let previous = location.hash, skipGuard = false; - const dirty = () => window.MosaicChat?.hasDraft?.() || window.MosaicAssistant?.hasDraft?.() || window.MosaicOrgCreate?.hasDraft?.() || window.MosaicWorkflows?.hasDraft?.() || [...document.querySelectorAll('#reply, #conv-reply, #doc-source')].some(el => !!el.value?.trim()); + // SP11: the Assistant keeps its panes' unsent text per thread, so it is not asked about here. + const dirty = () => window.MosaicChat?.hasDraft?.() || window.MosaicOrgCreate?.hasDraft?.() || window.MosaicWorkflows?.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. function rescope(seg) { const from = current; diff --git a/packages/webui/tests/assistant-threads-browser.test.mjs b/packages/webui/tests/assistant-threads-browser.test.mjs index 096634248..d2ac8924a 100644 --- a/packages/webui/tests/assistant-threads-browser.test.mjs +++ b/packages/webui/tests/assistant-threads-browser.test.mjs @@ -51,7 +51,7 @@ function orgs(state) { shares: async () => [], messages: async ({ thread }) => [ ...(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.hideSent ? [] : 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 }) => { @@ -351,8 +351,11 @@ test('SP7: with three open, a fourth thread replaces the first pane with no unse assert.deepEqual(await b.evaluate(panes), [`alpha:${T.a1}`, `home:${T.h1}`, `alpha:${T.a2}`]); assert.deepEqual(await b.evaluate(`[${field(`alpha:${T.a1}`)}.value, ${field(`home:${T.h1}`)}.value, ${field(`alpha:${T.a2}`)}.value]`), ['alpha draft', 'home draft', 'kickoff draft']); assert.equal(await b.evaluate(query), `alpha:${T.a1},home:${T.h1},alpha:${T.a2}`); - // Closing a pane clears the reason; the thread then opens. + // Closing a pane clears the reason; the thread then opens. SP11: the pane holds a message, so the + // close asks first. + await b.evaluate(`window.__asked = []; window.confirm = m => (window.__asked.push(m), true)`); await b.evaluate(`${PANE(`home:${T.h1}`)}.querySelector('button[data-assist-close]').click()`); + assert.equal(await b.evaluate('window.__asked.length'), 1); await wait(`!/not opened/.test(document.querySelector('[data-assist-note]').textContent)`); assert.equal(await b.evaluate(`document.querySelector('[data-assist-note]').hasAttribute('data-refused')`), false); await follow(b, `#/orgs/beta/projects/threads/${T.b1}`); @@ -371,6 +374,67 @@ test('SP7: each pane names its own organization\'s Messages page, and the bar no assert.doesNotMatch(await b.evaluate(`document.querySelector('.assist-bar').innerText`), /Messages/); }); +// SP11 (#1873): a pane's unsent message is kept per thread while the tab is open. Leaving the +// Assistant page, by the sidebar or by a pane's Full view into another organization, keeps it, and the +// pane shows it again when it opens. Closing a pane that holds one asks first. +test('SP11: an unsent pane message survives leaving the Assistant page, through Full view too, and closing a pane with one asks first', async t => { + const state = { down: new Set(), sent: [], created: [] }; + const a1 = `alpha:${T.a1}`, b1 = `beta:${T.b1}`, both = `#/assistant?open=${a1},${b1}`; + const { b, wait } = await open(t, { hash: both, state }); + await wait(`${ready(a1)} && ${ready(b1)} && !${field(a1)}.disabled && !${field(b1)}.disabled`); + await type(b, a1, 'alpha keep'); + await type(b, b1, 'beta keep'); + // Away to Tasks and back. + await b.evaluate(`location.hash = '#/tasks'`); + await wait(`!document.querySelector('[data-assist-pane]') && !!document.querySelector('[data-tasks-board]')`); + await b.evaluate(`location.hash = ${JSON.stringify(both)}`); + await wait(`${field(a1)}?.value === 'alpha keep' && ${field(b1)}?.value === 'beta keep'`); + // Away through Beta's Full view: no discard question, and the message is still there after. + await b.evaluate(`${PANE(b1)}.querySelector('a[data-assist-pane-full]').click()`); + await wait(`location.hash.startsWith('#/orgs/beta/') && !document.querySelector('[data-assist-pane]')`); + assert.equal(await b.evaluate(`!!document.querySelector('dialog[open]')`), false, 'leaving asks nothing: the message is kept'); + await b.evaluate(`location.hash = ${JSON.stringify(both)}`); + await wait(`${field(a1)}?.value === 'alpha keep' && ${field(b1)}?.value === 'beta keep'`); + // Closing a pane with an unsent message asks; saying no keeps the pane. + await b.evaluate(`window.__asked = []; window.confirm = m => (window.__asked.push(m), false)`); + await b.evaluate(`${PANE(a1)}.querySelector('button[data-assist-close]').click()`); + assert.deepEqual(await b.evaluate('window.__asked'), ['Close Launch email? Its unsent message is lost.']); + assert.deepEqual(await b.evaluate(panes), [a1, b1]); + // Saying yes closes it and drops the message: the thread opens again empty. + await b.evaluate(`window.confirm = m => (window.__asked.push(m), true)`); + await b.evaluate(`${PANE(a1)}.querySelector('button[data-assist-close]').click()`); + await wait(`document.querySelectorAll('[data-assist-pane]').length === 1`); + await follow(b, `#/orgs/alpha/projects/threads/${T.a1}`); + await wait(`${ready(a1)} && !!${field(a1)} && !${field(a1)}.disabled`); + assert.equal(await b.evaluate(`${field(a1)}.value`), ''); + // A pane with nothing typed closes with no question. + await b.evaluate(`${PANE(a1)}.querySelector('button[data-assist-close]').click()`); + await wait(`document.querySelectorAll('[data-assist-pane]').length === 1`); + assert.equal(await b.evaluate('window.__asked.length'), 2); + assert.deepEqual(state.sent, []); +}); + +// SP11 (#1873): a pane whose message was sent but not yet shown in the conversation is not free: a +// fourth thread replaces the first pane that holds nothing, and that pane only once its receipt is in. +test('SP11: a pane whose send still waits for its receipt is not replaced by a fourth thread', async t => { + const state = { down: new Set(), sent: [], created: [], hideSent: true }; + const a1 = `alpha:${T.a1}`, b1 = `beta:${T.b1}`, a2 = `alpha:${T.a2}`, h1 = `home:${T.h1}`; + const { b, wait } = await open(t, { hash: `#/assistant?open=${a1},${b1},${a2}`, state }); + await wait(`${ready(a1)} && ${ready(b1)} && ${ready(a2)} && !${field(a1)}.disabled`); + await b.evaluate(`(() => { const el = ${field(a1)}; el.value = 'in flight'; el.dispatchEvent(new Event('input', { bubbles: true })); el.form.requestSubmit(); })()`); + await wait(`/Accepted message/.test(${PANE(a1)}.innerText) && ${field(a1)}.value === ''`); + await follow(b, `#/orgs/home/projects/threads/${T.h1}`); + await wait(ready(h1)); + assert.deepEqual(await b.evaluate(panes), [a1, h1, a2], 'the waiting pane stays; the first free one gives way'); + // The message shows: the receipt is in and the pane is free again. + state.hideSent = false; + await b.evaluate('window.MosaicAssistant.refresh()'); + await wait(`/Message shown in this conversation/.test(${PANE(a1)}.innerText)`); + await follow(b, `#/orgs/beta/projects/threads/${T.b1}`); + await wait(ready(b1)); + assert.deepEqual(await b.evaluate(panes), [b1, h1, a2]); +}); + // SP7 review: a pane has no Request details, sessions or sharing, so each pane links its thread's full // view, and a link marked data-full-view is not turned into a pane. test('SP7: each pane links its thread\'s full view, and one plain click reaches it', async t => {