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:
1 parent
d50d79cddd
commit
9cff3255e6
4 files changed
+100
-11
No files matched your search
@@ -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]');
|
||||
|
||||
@@ -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 });
|
||||
})();
|
||||
@@ -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 => {
|
||||
|
||||
Reference in new issue
Block a user