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)

This commit is contained in:
jason.woltje committed 2026-11-15 10:27:27 -06:00
1 parent d50d79cddd
commit 9cff3255e6
4 files changed
+100 -11

No files matched your search

+26 -6
View File
@@ -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]');
+6 -2
View File
@@ -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 });
})();
+2 -1
View File
@@ -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;
@@ -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 => {