// SP6 (#1873): the Assistant is one system page. Up to three chat threads from any organization // open next to each other, each the chat thread view pinned to its own organization. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { startServer } from '../src/serve.mjs'; import { createDevOwner } from '../src/dev-owner.mjs'; import { browser } from './browser.mjs'; import { close } from './fixture.mjs'; const P = { alpha: '10000000-0000-4000-8000-00000000000a', beta: '10000000-0000-4000-8000-00000000000b', home: '10000000-0000-4000-8000-00000000000c' }; const T = { a1: '20000000-0000-4000-8000-0000000000a1', a2: '20000000-0000-4000-8000-0000000000a2', b1: '20000000-0000-4000-8000-0000000000b1', h1: '20000000-0000-4000-8000-0000000000c1', }; const NEW = '20000000-0000-4000-8000-0000000000b9'; // Three organizations. `state.down` names the ones whose bus is stopped; `state.sent` and // `state.created` record every chat write with the organization whose transport received it. function orgs(state) { const ORGS = [ { id: 'alpha', label: 'Alpha', theme: 'rosewood' }, { id: 'beta', label: 'Beta', theme: 'cobalt' }, { id: 'home', label: 'Home', theme: 'grove' }, ]; const PROJECTS = { alpha: [{ id: P.alpha, name: 'Spring launch' }], beta: [{ id: P.beta, name: 'API gateway' }], home: [{ id: P.home, name: 'Garden' }] }; const THREADS = { alpha: [{ id: T.a1, project: P.alpha, name: 'Launch email', instance: 'assistant' }, { id: T.a2, project: P.alpha, name: 'Kickoff notes', instance: 'assistant' }], beta: [{ id: T.b1, project: P.beta, name: 'Gateway auth', instance: 'assistant' }], home: [{ id: T.h1, project: P.home, name: 'Seed order', instance: 'assistant' }], }; 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.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 || 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 { busHealthy: true, send: async () => ({}), reads: { business: async () => ({ business: { id } }), settings: async () => ({}), busStatus: async () => ({ live: true, down: false }), queue: async () => ({ rows: [] }) }, bus: { inbox: async () => [], agents: async () => [], tasks: async () => [], 'thread.claims': async () => [], messages: async () => ({ messages: [], unread: 0 }), instances: async () => ({ business: id, revision: 'a'.repeat(64), definitions: {}, instances: { assistant: { definition: 'assistant', vars: {}, ...(state.run?.[id] ? { running: state.run[id] } : {}) } } }), }, chat: { projects: async () => PROJECTS[id], threads: async ({ shared } = {}) => (shared ? [] : threads()), sessions: async ({ thread }) => [{ id: thread, name: 'default', default: true }], 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.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 }) => { const sent = { org: id, thread, body, id: `40000000-0000-4000-8000-${String(state.sent.length + 1).padStart(12, '0')}` }; state.sent.push(sent); return { id: sent.id }; }, createThread: async ({ project, name, instance }) => { state.created.push({ org: id, project, name, instance, id: NEW }); return { id: NEW, instance }; }, }, }; }, }; } async function open(t, { width = 1440, height = 900, hash, state = { down: new Set(['home']), sent: [], created: [] } } = {}) { const organizations = orgs(state); const server = await startServer({ port: 0, sso: createDevOwner(), organizations, send: async () => ({}), board: 'http://127.0.0.1:9', log: () => {} }); t.after(() => close(server)); const b = await browser({ machine: false }); t.after(() => b.close()); await b.viewport(width, height); const base = `http://127.0.0.1:${server.address().port}/`; await b.navigate(base + hash); const wait = (expression, tries = 200) => b.evaluate(`(async()=>{for(let i=0;i<${tries};i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`); return { b, base, wait, state }; } const PANE = sel => `document.querySelector('[data-assist-pane="${sel}"]')`; const panes = `[...document.querySelectorAll('[data-assist-pane]')].map(p => p.dataset.assistPane)`; const shown = `[...document.querySelectorAll('[data-assist-pane]')].filter(p => p.getClientRects().length).map(p => p.dataset.assistPane)`; const query = `new URLSearchParams(location.hash.split('?')[1] || '').get('open')`; const ready = sel => `!!${PANE(sel)}?.querySelector('.chat-log li') && !!${PANE(sel)}?.querySelector('[data-ws-orgchip]')`; const trail = '(document.getElementById("breadcrumbs").getClientRects().length ? document.getElementById("breadcrumbs").innerText.replace(/\\s+/g, " ").trim() : null)'; const SENT = '/Accepted message|Message shown in this conversation/'; const pick = (b, value) => b.evaluate(`(() => { const s = document.querySelector('[data-assist-open]'); s.value = ${JSON.stringify(value)}; if (s.value !== ${JSON.stringify(value)}) throw Error('no option ' + ${JSON.stringify(value)}); s.dispatchEvent(new Event('change', { bubbles: true })); })()`); test('the sidebar Assistant opens one system page, and two threads from two organizations open side by side with their chips', async t => { const { b, wait } = await open(t, { hash: '#/orgs/alpha/tasks' }); await wait(`!!document.querySelector('#sections a[href="#/assistant"]')`); assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('#sections a')].map(a => a.textContent.trim())`).then(v => v.includes('Side by side')), false, 'no Side by side item'); await b.evaluate(`document.querySelector('#sections a[href="#/assistant"]').click()`); await wait(`location.hash.startsWith('#/assistant') && document.querySelector('#bus-view h1')?.textContent === 'Assistant'`); assert.equal(await b.evaluate('document.querySelector(\'#sections a[href="#/assistant"]\').getAttribute("aria-current")'), 'page'); // SP7: the page name shows once, as the heading. assert.equal(await b.evaluate(trail), null); await wait(`!!document.querySelector('[data-assist-open] optgroup[label^="Beta"] option[value="beta:${T.b1}"]')`); await pick(b, `alpha:${T.a1}`); await pick(b, `beta:${T.b1}`); await wait(`${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)}`); assert.deepEqual(await b.evaluate(panes), [`alpha:${T.a1}`, `beta:${T.b1}`]); assert.deepEqual(await b.evaluate(shown), [`alpha:${T.a1}`, `beta:${T.b1}`], 'both panes show at 1440'); const head = sel => b.evaluate(`(() => { const p = ${PANE(sel)}; return { title: p.querySelector('[data-assist-title]').textContent, org: p.querySelector('[data-ws-orgchip]').textContent, project: p.querySelector('[data-ws-projchip]')?.textContent ?? null, close: !!p.querySelector('button[data-assist-close]') }; })()`); assert.deepEqual(await head(`alpha:${T.a1}`), { title: 'Launch email', org: 'Alpha', project: 'Spring launch', close: true }); assert.deepEqual(await head(`beta:${T.b1}`), { title: 'Gateway auth', org: 'Beta', project: 'API gateway', close: true }); assert.match(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.innerText`), /Alpha draft is ready\./); assert.match(await b.evaluate(`${PANE(`beta:${T.b1}`)}.innerText`), /Beta tokens rotate nightly\./); assert.doesNotMatch(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.innerText`), /Beta tokens/); // One level-1 heading on the page; each pane's own form, field and ids are its own. assert.equal(await b.evaluate(`document.querySelectorAll('#bus-view h1').length`), 1); const ids = await b.evaluate(`[...document.querySelectorAll('#bus-view [id]')].map(e => e.id)`); assert.equal(new Set(ids).size, ids.length, 'no duplicate ids across panes'); assert.equal(await b.evaluate(`document.querySelectorAll('#bus-view form textarea').length`), 2); }); test('the open set and its order live in the address, so a reload or a pasted address restores the same panes', async t => { const hash = `#/assistant?open=beta:${T.b1},alpha:${T.a2}`; const { b, base, wait } = await open(t, { hash }); await wait(`${ready(`beta:${T.b1}`)} && ${ready(`alpha:${T.a2}`)}`); assert.deepEqual(await b.evaluate(panes), [`beta:${T.b1}`, `alpha:${T.a2}`]); await pick(b, `alpha:${T.a1}`); await wait(`${ready(`alpha:${T.a1}`)}`); assert.equal(await b.evaluate(query), `beta:${T.b1},alpha:${T.a2},alpha:${T.a1}`); const address = await b.evaluate('location.hash'); await b.navigate('about:blank'); await b.navigate(base + address); await wait(`${ready(`beta:${T.b1}`)} && ${ready(`alpha:${T.a2}`)} && ${ready(`alpha:${T.a1}`)}`); assert.deepEqual(await b.evaluate(panes), [`beta:${T.b1}`, `alpha:${T.a2}`, `alpha:${T.a1}`]); // A hand-edited address is checked before use: bad selectors, repeats and a fourth pane are dropped. await b.navigate('about:blank'); await b.navigate(base + `#/assistant?open=alpha:${T.a1},alpha:${T.a1},nope,../x:${T.b1},beta:not-a-uuid,beta:${T.b1},alpha:${T.a2},home:${T.h1}`); await wait(`document.querySelectorAll('[data-assist-pane]').length === 3`); assert.deepEqual(await b.evaluate(panes), [`alpha:${T.a1}`, `beta:${T.b1}`, `alpha:${T.a2}`]); await wait(`${query} === 'alpha:${T.a1},beta:${T.b1},alpha:${T.a2}'`); // With three open the picker offers no fourth. assert.equal(await b.evaluate(`document.querySelector('[data-assist-open]').disabled`), true); }); test('a send in organization B\'s pane reaches B\'s thread while organization A was opened last', async t => { const state = { down: new Set(['home']), sent: [], created: [] }; const { b, wait } = await open(t, { hash: '#/orgs/beta/tasks', state }); await wait(`window.MosaicOrg?.id === 'beta'`); await b.evaluate(`location.hash = '#/orgs/alpha/tasks'`); await wait(`window.MosaicOrg?.id === 'alpha'`); await b.evaluate(`location.hash = '#/assistant?open=beta:${T.b1},alpha:${T.a1}'`); await wait(`${ready(`beta:${T.b1}`)} && ${ready(`alpha:${T.a1}`)}`); assert.equal(await b.evaluate('window.MosaicOrg.id'), 'alpha', 'alpha is the organization opened last'); const field = `${PANE(`beta:${T.b1}`)}.querySelector('form textarea')`; await wait(`!${field}.disabled`); await b.evaluate(`(() => { const el = ${field}; el.value = 'rotate the beta key'; el.dispatchEvent(new Event('input', { bubbles: true })); el.form.requestSubmit(); })()`); await wait(`${SENT}.test(${PANE(`beta:${T.b1}`)}.innerText)`); assert.deepEqual(state.sent.map(({ org, thread, body }) => ({ org, thread, body })), [{ org: 'beta', thread: T.b1, body: 'rotate the beta key' }]); assert.doesNotMatch(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.innerText`), /Accepted message|Message shown in this conversation/, 'the other pane shows no receipt'); assert.equal(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.querySelector('form textarea').value`), '', 'the other pane keeps its own draft'); // A send in A's pane reaches A. const other = `${PANE(`alpha:${T.a1}`)}.querySelector('form textarea')`; await b.evaluate(`(() => { const el = ${other}; el.value = 'alpha copy'; el.dispatchEvent(new Event('input', { bubbles: true })); el.form.requestSubmit(); })()`); await wait(`${SENT}.test(${PANE(`alpha:${T.a1}`)}.innerText)`); assert.deepEqual(state.sent.map(({ org, thread }) => `${org}:${thread}`), [`beta:${T.b1}`, `alpha:${T.a1}`]); // Live updates: a refresh shows the sent message in its own pane only. await b.evaluate('window.MosaicAssistant.refresh()'); await wait(`/rotate the beta key/.test(${PANE(`beta:${T.b1}`)}.querySelector('.chat-log').innerText)`); assert.doesNotMatch(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.querySelector('.chat-log').innerText`), /rotate the beta key/); }); test('closing a pane updates the address and leaves the others', async t => { const { b, wait } = await open(t, { hash: `#/assistant?open=alpha:${T.a1},beta:${T.b1}` }); await wait(`${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)}`); await b.evaluate(`${PANE(`alpha:${T.a1}`)}.querySelector('button[data-assist-close]').click()`); await wait(`document.querySelectorAll('[data-assist-pane]').length === 1`); assert.equal(await b.evaluate(query), `beta:${T.b1}`); assert.match(await b.evaluate(`${PANE(`beta:${T.b1}`)}.innerText`), /Beta tokens rotate nightly\./); await b.evaluate(`${PANE(`beta:${T.b1}`)}.querySelector('button[data-assist-close]').click()`); await wait(`document.querySelectorAll('[data-assist-pane]').length === 0`); assert.equal(await b.evaluate(`location.hash`), '#/assistant'); assert.match(await b.evaluate(`document.getElementById('bus-view').innerText`), /No thread is open/); }); test('a stopped organization is named at the top, its pane says so, and the other panes keep working', async t => { const state = { down: new Set(['home']), sent: [], created: [] }; const { b, wait } = await open(t, { hash: `#/assistant?open=alpha:${T.a1},home:${T.h1},beta:${T.b1}`, state }); await wait(`!!document.querySelector('#bus-view [data-ws-unread]') && ${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)}`); assert.match(await b.evaluate(`document.querySelector('#bus-view [data-ws-unread]').textContent`), /Home/); assert.doesNotMatch(await b.evaluate(`document.querySelector('#bus-view [data-ws-unread]').textContent`), /Alpha|Beta/); await wait(`/bus is stopped/i.test(${PANE(`home:${T.h1}`)}?.querySelector('[data-assist-line]')?.textContent || '')`); assert.equal(await b.evaluate(`${PANE(`home:${T.h1}`)}.querySelector('[data-ws-orgchip]').textContent`), 'Home'); assert.equal(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.querySelector('form textarea').disabled`), false); // Beta's bus stops while its pane is open, with an unsent message in it: that pane says so and // only so, and holds the message; Alpha's still sends. 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.down.add('beta'); await b.evaluate('window.MosaicAssistant.refresh()'); await wait(`/bus is stopped/i.test(${PANE(`beta:${T.b1}`)}.querySelector('[data-assist-line]')?.textContent || '') && /Beta/.test(document.querySelector('#bus-view [data-ws-unread]')?.textContent || '')`); await wait(`!${betaField}`); const betaBody = await b.evaluate(`${PANE(`beta:${T.b1}`)}.querySelector('.assist-body').innerText`); assert.match(betaBody, /Nothing is read for this thread while Beta's bus is stopped\. Your unsent message is kept/); assert.doesNotMatch(betaBody, /Refresh to try again|Read again|Conversation unavailable/, 'no line that says a refresh would help'); assert.equal(await b.evaluate('window.MosaicAssistant.hasDraft()'), true, 'the held message counts as a draft'); const field = `${PANE(`alpha:${T.a1}`)}.querySelector('form textarea')`; assert.equal(await b.evaluate(`${field}.disabled`), false); await b.evaluate(`(() => { const el = ${field}; el.value = 'still here'; el.dispatchEvent(new Event('input', { bubbles: true })); el.form.requestSubmit(); })()`); await wait(`${SENT}.test(${PANE(`alpha:${T.a1}`)}.innerText)`); assert.deepEqual(state.sent.map(({ org, body }) => `${org}:${body}`), ['alpha:still here']); // The picker names the stopped organizations and offers none of their threads. assert.match(await b.evaluate(`[...document.querySelectorAll('[data-assist-open] optgroup')].map(g => g.label).join('|')`), /Home ยท bus stopped/); // Beta's bus starts again: its pane reads again and the held message is back in its composer. state.down.delete('beta'); await b.evaluate('window.MosaicAssistant.refresh()'); await wait(`${ready(`beta:${T.b1}`)} && ${betaField}?.value === 'beta draft'`); 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 }); await wait(`${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)}`); state.replies.push({ org: 'beta', thread: T.b1, body: 'Rotation finished.' }); // The page polls every 10 s; wait up to 15 s without calling refresh. await wait(`/Rotation finished\./.test(${PANE(`beta:${T.b1}`)}.querySelector('.chat-log').innerText)`, 500); assert.doesNotMatch(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.querySelector('.chat-log').innerText`), /Rotation finished/); }); test('the old Side by side address lands on the Assistant page', async t => { const { b, wait } = await open(t, { hash: '#/orgs/alpha/side-by-side?a=alpha/organization&b=beta/organization&w=50&show=a' }); await wait(`location.hash === '#/assistant' && document.querySelector('#bus-view h1')?.textContent === 'Assistant'`); assert.equal(await b.evaluate(`!!window.MosaicPanes`), false, 'the Side by side module is gone'); await b.evaluate(`location.hash = '#/side-by-side'`); await wait(`location.hash === '#/assistant'`); // The old per-organization Assistant address, the messages page, still opens. await b.evaluate(`location.hash = '#/orgs/alpha/messages'`); await wait(`document.querySelector('#bus-view h1')?.textContent === 'Messages'`); }); test('at 390 one pane shows at a time with a tab strip to switch', async t => { const { b, base, wait } = await open(t, { width: 390, height: 844, hash: `#/assistant?open=alpha:${T.a1},beta:${T.b1}` }); await wait(`${ready(`alpha:${T.a1}`)} && document.querySelectorAll('[data-assist-tabs] [data-assist-show]').length === 2`); assert.deepEqual(await b.evaluate(shown), [`alpha:${T.a1}`]); assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-assist-tabs] [data-assist-show]')].map(x => [x.textContent.trim(), x.getAttribute('aria-pressed')])`), [['Launch email', 'true'], ['Gateway auth', 'false']]); await b.evaluate(`document.querySelector('[data-assist-tabs] [data-assist-show="beta:${T.b1}"]').click()`); await wait(`${ready(`beta:${T.b1}`)}`); assert.deepEqual(await b.evaluate(shown), [`beta:${T.b1}`]); assert.equal(await b.evaluate(`new URLSearchParams(location.hash.split('?')[1]).get('show')`), `beta:${T.b1}`); assert.equal(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), true, 'no horizontal page scroll'); // A pasted address names the shown pane however its id is cased. await b.navigate('about:blank'); await b.navigate(base + `#/assistant?open=alpha:${T.a1},beta:${T.b1}&show=beta:${T.b1.toUpperCase()}`); await wait(`${ready(`beta:${T.b1}`)}`); assert.deepEqual(await b.evaluate(shown), [`beta:${T.b1}`]); }); test('"Open thread" starts a new thread in a chosen organization through that organization\'s own route', async t => { const state = { down: new Set(['home']), sent: [], created: [] }; const { b, wait } = await open(t, { hash: '#/assistant', state }); await wait(`!!document.querySelector('[data-assist-open] option[value="new:beta"]')`); await pick(b, 'new:beta'); await wait(`!!document.querySelector('[data-assist-new] select[name=project] option[value="${P.beta}"]') && !!document.querySelector('[data-assist-new] select[name=instance] option[value=assistant]')`); await b.evaluate(`(() => { const f = document.querySelector('[data-assist-new]'); f.elements.name.value = 'Rate limits'; f.requestSubmit(); })()`); await wait(`${ready(`beta:${NEW}`)} || !!${PANE(`beta:${NEW}`)}?.querySelector('form textarea')`); assert.deepEqual(state.created.map(({ org, project, name, instance }) => ({ org, project, name, instance })), [{ org: 'beta', project: P.beta, name: 'Rate limits', instance: 'assistant' }]); assert.equal(await b.evaluate(query), `beta:${NEW}`); }); // SP7: a thread link anywhere opens that thread as a pane here; a typed per-organization address // still opens that organization's own chat view. const follow = (b, href) => b.evaluate(`(() => { const a = document.createElement('a'); a.href = ${JSON.stringify(href)}; a.textContent = 'thread'; document.body.append(a); a.click(); a.remove(); })()`); const type = (b, sel, text) => b.evaluate(`(() => { const el = ${PANE(sel)}.querySelector('form textarea'); el.value = ${JSON.stringify(text)}; el.dispatchEvent(new Event('input', { bubbles: true })); })()`); const field = sel => `${PANE(sel)}?.querySelector('form textarea')`; test('SP7: a thread link on a project page opens the thread as a pane on the Assistant page, added to the open set', async t => { const { b, wait } = await open(t, { hash: `#/assistant?open=beta:${T.b1}` }); await wait(ready(`beta:${T.b1}`)); await b.evaluate(`location.hash = '#/orgs/alpha/projects/${P.alpha}'`); await wait(`!!document.querySelector('#bus-view a[href$="/threads/${T.a1}"]')`); await b.evaluate(`document.querySelector('#bus-view a[href$="/threads/${T.a1}"]').click()`); await wait(`location.hash.startsWith('#/assistant') && ${ready(`alpha:${T.a1}`)}`); assert.deepEqual(await b.evaluate(panes), [`beta:${T.b1}`, `alpha:${T.a1}`]); assert.equal(await b.evaluate(query), `beta:${T.b1},alpha:${T.a1}`); // A per-organization chat address typed or pasted still opens that organization's chat view. await b.evaluate(`location.hash = '#/orgs/beta/projects/threads/${T.b1}'`); await wait(`!document.querySelector('[data-assistant]') && /Beta tokens rotate nightly/.test(document.getElementById('bus-view').innerText)`); assert.equal(await b.evaluate('location.hash'), `#/orgs/beta/projects/threads/${T.b1}`); }); // SP11 (#1873): Resume assistant shows only when the organization's instances read says the thread's // agent runs no process (paused or stopped). An agent the read does not say about keeps the control. test('Resume assistant shows only in a pane whose agent the instances read says is not running', async t => { const state = { down: new Set(), sent: [], created: [], run: { alpha: ['run-1'], beta: [] } }; const { b, wait } = await open(t, { hash: `#/assistant?open=alpha:${T.a1},beta:${T.b1},home:${T.h1}`, state }); const resume = sel => `!!${PANE(sel)}.querySelector('button.pane-resume')`; await wait(`${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)} && ${ready(`home:${T.h1}`)}`); await wait(resume(`beta:${T.b1}`)); assert.equal(await b.evaluate(resume(`alpha:${T.a1}`)), false, 'a running assistant offers no resume'); assert.equal(await b.evaluate(resume(`home:${T.h1}`)), true, 'a read that does not say keeps the control'); // Alpha's assistant stops: the next read shows the control. state.run.alpha = []; await b.evaluate('window.MosaicAssistant.refresh()'); await wait(resume(`alpha:${T.a1}`)); // Beta's starts again: its control goes. state.run.beta = ['run-2']; await b.evaluate('window.MosaicAssistant.refresh()'); await wait(`!${resume(`beta:${T.b1}`)}`); }); test('SP7: with three open, a fourth thread replaces the first pane with no unsent message; when all three hold one, nothing is replaced and the page says why', async t => { const state = { down: 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}`)} && !${field(`alpha:${T.a1}`)}.disabled`); await type(b, `alpha:${T.a1}`, 'alpha draft'); // On the Assistant page a thread link adds a pane in place: the typed message stays. await follow(b, `#/orgs/alpha/projects/threads/${T.a2}`); await wait(ready(`alpha:${T.a2}`)); assert.deepEqual(await b.evaluate(panes), [`alpha:${T.a1}`, `beta:${T.b1}`, `alpha:${T.a2}`]); assert.equal(await b.evaluate(`${field(`alpha:${T.a1}`)}.value`), 'alpha draft'); // A fourth: Alpha's first pane holds a message, so Beta's pane, the first without one, gives way. await follow(b, `#/orgs/home/projects/threads/${T.h1}`); await wait(ready(`home:${T.h1}`)); assert.deepEqual(await b.evaluate(panes), [`alpha:${T.a1}`, `home:${T.h1}`, `alpha:${T.a2}`]); assert.equal(await b.evaluate(query), `alpha:${T.a1},home:${T.h1},alpha:${T.a2}`); assert.equal(await b.evaluate(`${field(`alpha:${T.a1}`)}.value`), 'alpha draft'); // All three hold a message: nothing is replaced and the page says why. await wait(`!${field(`home:${T.h1}`)}.disabled && !${field(`alpha:${T.a2}`)}.disabled`); await type(b, `home:${T.h1}`, 'home draft'); await type(b, `alpha:${T.a2}`, 'kickoff draft'); await follow(b, `#/orgs/beta/projects/threads/${T.b1}`); await wait(`/not opened/.test(document.querySelector('[data-assist-note]').textContent)`); assert.match(await b.evaluate(`document.querySelector('[data-assist-note]').textContent`), /all 3 panes hold an unsent message/); assert.equal(await b.evaluate(`document.querySelector('[data-assist-note]').hasAttribute('data-refused')`), true); 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. 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}`); await wait(ready(`beta:${T.b1}`)); assert.deepEqual(await b.evaluate(panes), [`alpha:${T.a1}`, `alpha:${T.a2}`, `beta:${T.b1}`]); assert.deepEqual(state.sent, []); }); test('SP7: each pane names its own organization\'s Messages page, and the bar no longer names the organization opened last', async t => { const { b, wait } = await open(t, { hash: '#/orgs/beta/tasks' }); await wait(`window.MosaicOrg?.id === 'beta'`); await b.evaluate(`location.hash = '#/assistant?open=alpha:${T.a1},beta:${T.b1}'`); await wait(`${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)}`); assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-assist-pane]')].map(p => { const a = p.querySelector('.assist-pane-h a[data-assist-pane-messages]'); return a && [a.getAttribute('href'), a.getAttribute('aria-label'), a.textContent.trim()]; })`), [['#/orgs/alpha/messages', 'Messages in Alpha', 'Messages'], ['#/orgs/beta/messages', 'Messages in Beta', 'Messages']]); 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 review: signing out drops every kept pane message, so the next person to sign in on this tab // never sees text typed before, after a reload too. test('SP11: signing out drops kept pane messages, and a reload after it opens the pane empty', async t => { const state = { down: new Set(), sent: [], created: [] }; const a1 = `alpha:${T.a1}`, hash = `#/assistant?open=${a1}`; const { b, base, wait } = await open(t, { hash, state }); await wait(`${ready(a1)} && !${field(a1)}.disabled`); await type(b, a1, 'private note'); await b.evaluate(`location.hash = '#/tasks'`); await wait(`!document.querySelector('[data-assist-pane]')`); assert.match(await b.evaluate(`sessionStorage.getItem('mosaic-assistant-drafts') || ''`), /private note/); await b.evaluate(`location.hash = ${JSON.stringify(hash)}`); await wait(`${field(a1)}?.value === 'private note'`); await b.evaluate(`document.dispatchEvent(new Event('mosaic-signed-out')), true`); await wait(`document.body.classList.contains('signed-out')`); assert.equal(await b.evaluate(`sessionStorage.getItem('mosaic-assistant-drafts')`), null); // A query string makes it a real load; the same address with only a hash would not reload. await b.navigate(base + '?again=1' + hash); await wait(`!document.body.classList.contains('signed-out') && ${ready(a1)} && !!${field(a1)} && !${field(a1)}.disabled`); assert.equal(await b.evaluate(`${field(a1)}.value`), ''); assert.equal(await b.evaluate(`sessionStorage.getItem('mosaic-assistant-drafts')`), null); assert.deepEqual(state.sent, []); }); // SP11 review: the Sign out form is a plain POST that unloads the page; its submit drops the kept // messages and the pagehide it causes keeps nothing, so a sign-in after it opens the pane empty. test('SP11: the Sign out form drops kept pane messages, and the page it unloads keeps none', async t => { const state = { down: new Set(), sent: [], created: [] }; const a1 = `alpha:${T.a1}`, hash = `#/assistant?open=${a1}`; const { b, base, wait } = await open(t, { hash, state }); await wait(`${ready(a1)} && !${field(a1)}.disabled`); await type(b, a1, 'private note'); await b.evaluate(`location.hash = '#/tasks'`); await wait(`!document.querySelector('[data-assist-pane]')`); await b.evaluate(`location.hash = ${JSON.stringify(hash)}`); await wait(`${field(a1)}?.value === 'private note'`); await b.evaluate(`document.getElementById('sign-out').requestSubmit(), true`); // The POST unloads the page; poll from here, since the old page's context goes away under it. let left = false; for (let i = 0; i < 200 && !left; i++) { try { left = await b.evaluate(`location.pathname === '/auth/logout'`); } catch { /* navigating */ } if (!left) await new Promise(r => setTimeout(r, 30)); } assert.equal(left, true, 'the Sign out form posted'); await b.navigate(base + '?again=1' + hash); await wait(`${ready(a1)} && !!${field(a1)} && !${field(a1)}.disabled`); assert.equal(await b.evaluate(`${field(a1)}.value`), ''); assert.equal(await b.evaluate(`sessionStorage.getItem('mosaic-assistant-drafts')`), null); 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 => { const { b, wait } = await open(t, { hash: `#/assistant?open=alpha:${T.a1},beta:${T.b1}` }); await wait(`${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)}`); assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-assist-pane]')].map(p => { const a = p.querySelector('.assist-pane-h a[data-assist-pane-full]'); return a && [a.getAttribute('href'), a.textContent.trim(), a.hasAttribute('data-full-view')]; })`), [[`#/orgs/alpha/projects/threads/${T.a1}`, 'Full view', true], [`#/orgs/beta/projects/threads/${T.b1}`, 'Full view', true]]); // Each pane's Full view names its thread and organization, so a screen reader can tell three apart. assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-assist-pane-full]')].map(a => a.getAttribute('aria-label'))`), ['Full view of Launch email, Alpha', 'Full view of Gateway auth, Beta']); await b.evaluate(`${PANE(`beta:${T.b1}`)}.querySelector('a[data-assist-pane-full]').click()`); await wait(`!document.querySelector('[data-assistant]') && /Beta tokens rotate nightly/.test(document.getElementById('bus-view').innerText)`); assert.equal(await b.evaluate('location.hash'), `#/orgs/beta/projects/threads/${T.b1}`); }); test('SP7: a link marked as the full view stays in the organization\'s own view, from any page', async t => { const { b, wait } = await open(t, { hash: `#/assistant?open=alpha:${T.a1}` }); await wait(ready(`alpha:${T.a1}`)); // On the Assistant page itself: no pane is added, the browser follows the link. await b.evaluate(`(() => { const a = document.createElement('a'); a.href = '#/orgs/beta/projects/threads/${T.b1}'; a.setAttribute('data-full-view', ''); a.textContent = 'thread'; document.body.append(a); a.click(); a.remove(); })()`); await wait(`location.hash === '#/orgs/beta/projects/threads/${T.b1}'`); await wait(`!document.querySelector('[data-assistant]') && /Beta tokens rotate nightly/.test(document.getElementById('bus-view').innerText)`); // From another page: the same. await b.evaluate(`(() => { const a = document.createElement('a'); a.href = '#/orgs/alpha/projects/threads/${T.a1}'; a.setAttribute('data-full-view', ''); a.textContent = 'thread'; document.body.append(a); a.click(); a.remove(); })()`); await wait(`location.hash === '#/orgs/alpha/projects/threads/${T.a1}' && !document.querySelector('[data-assistant]')`); }); // SP7 review shots: the title and the pane's tools (Full view, Messages, close) share the header's // first row, the organization and project chips sit under it, and nothing spills out of the pane. const headerRows = `[...document.querySelectorAll('[data-assist-pane]')].map(p => { const h = p.querySelector('.assist-pane-h').getBoundingClientRect(); const r = s => p.querySelector(s).getBoundingClientRect(); const mid = q => Math.round(q.top + q.height / 2); const row = ['[data-assist-title]', '[data-assist-pane-full]', '[data-assist-pane-messages]', '[data-assist-close]'].map(s => mid(r(s))); const inside = [...p.querySelectorAll('.assist-pane-h > *, .assist-pane-h a, .assist-pane-h button')].every(e => { const q = e.getBoundingClientRect(); return q.left >= h.left - 1 && q.right <= h.right + 1; }); return { oneRow: Math.max(...row) - Math.min(...row) <= 4, chipsBelow: r('[data-assist-chips]').top >= r('[data-assist-title]').bottom - 1, inside }; })`; // At 800 three panes are too narrow for one row: the tools wrap, and none of them leaves the pane. const THREE = [`alpha:${T.a1}`, `alpha:${T.a2}`, `beta:${T.b1}`]; for (const [width, height, keys, oneRow] of [[1440, 900, THREE, true], [800, 900, THREE, null], [761, 900, THREE, null], [390, 844, [`beta:${T.b1}`], true]]) { test(`SP7: a pane header keeps its tools inside the pane${oneRow ? ', title and tools on one row with the chips below' : ''}, at ${width}`, async t => { const { b, wait } = await open(t, { width, height, hash: `#/assistant?open=${keys.join(',')}` }); await wait(keys.map(k => ready(k)).join(' && ')); await wait(`[...document.querySelectorAll('[data-assist-pane] [data-ws-orgchip]')].length === ${keys.length}`); const rows = await b.evaluate(headerRows); assert.deepEqual(rows.map(r => (oneRow ? r : { ...r, oneRow: null })), keys.map(() => ({ oneRow, chipsBelow: true, inside: true }))); }); }