From b9824049638d0888c4125d8f587222bf6a1bf541 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Nov 2026 04:51:23 -0600 Subject: [PATCH] fix(webui): sp6 review round, a stopped organization's pane stops reading and keeps its unsent message, an open pane picks up new replies on its own, a pasted thread id in any case shows its pane (#1873, lane sp6-threads) A pane whose organization's bus is stopped or unavailable no longer polls or offers Refresh; it says nothing is read until the bus starts again, holds any typed draft, and restores it untouched when the bus returns. Unsent pane drafts now count for the organization-switch and Console-restart guards. The Assistant sidebar item drops the Messages unread badge. "Start thread" is the primary action. Tests cover the stopped pane's draft, a reply arriving with no refresh, and an uppercase show id. --- packages/webui/src/public/assistant.css | 2 + packages/webui/src/public/assistant.js | 32 +++++++++---- packages/webui/src/public/bus.js | 2 +- packages/webui/src/public/chat.js | 6 +-- packages/webui/src/public/console-restart.js | 2 +- packages/webui/src/public/display.js | 2 +- .../tests/assistant-threads-browser.test.mjs | 47 +++++++++++++++---- 7 files changed, 69 insertions(+), 24 deletions(-) diff --git a/packages/webui/src/public/assistant.css b/packages/webui/src/public/assistant.css index f5aaadbea..34d06f798 100644 --- a/packages/webui/src/public/assistant.css +++ b/packages/webui/src/public/assistant.css @@ -23,6 +23,8 @@ html[data-organizations] .assist-new-fields{display:grid;grid-template-columns:r html[data-organizations] .assist-new-fields label{display:grid;gap:4px;font-size:13px;color:var(--muted);min-width:0} html[data-organizations] .assist-new-fields select,html[data-organizations] .assist-new-fields input{font:500 14px/1.4 var(--font);color:var(--text);background:var(--canvas);border:1px solid var(--line);border-radius:var(--r);padding:6px 8px;min-width:0} html[data-organizations] .assist-new-actions{display:flex;gap:8px;flex-wrap:wrap} +html[data-organizations] .assist-new .ws-btn.primary{background:var(--action);color:var(--onAction);border-color:var(--action)} +html[data-organizations] .assist-new .ws-btn.primary:hover:not(:disabled){background:color-mix(in srgb,var(--action) 88%,var(--strong))} html[data-organizations] .assist-new [data-assist-new-status]{margin:0;font-size:13px} html[data-organizations] .assist-new [data-assist-new-status]:empty{display:none} diff --git a/packages/webui/src/public/assistant.js b/packages/webui/src/public/assistant.js index d2ad4b5ab..ea3aba28d 100644 --- a/packages/webui/src/public/assistant.js +++ b/packages/webui/src/public/assistant.js @@ -36,7 +36,8 @@ const key = `${org}:${id}`; if (!keys.includes(key) && keys.length < MAX) keys.push(key); } - const show = q.get('show'); + const want = q.get('show') || '', at = want.indexOf(':'); + const show = at > 0 ? `${want.slice(0, at)}:${want.slice(at + 1).toLowerCase()}` : null; return { keys, show: keys.includes(show) ? show : null, named: q.has('open') }; } const split = key => { const cut = key.indexOf(':'); return { org: key.slice(0, cut), id: key.slice(cut + 1) }; }; @@ -185,7 +186,7 @@ function paintTop() { const { root, list, o, panes } = mounted; setHtml(root.querySelector('[data-assist-unread]'), list ? ws().unreadNote(o, [], 'assistant') : '

The organization list could not be read. Open panes only read.

'); - setText(root.querySelector('[data-assist-note]'), panes.length ? `${panes.length} of ${MAX} open` : ''); + setText(root.querySelector('[data-assist-note]'), panes.length ? `${panes.length} open, up to ${MAX}` : ''); const pick = root.querySelector('[data-assist-open]'); if (document.activeElement !== pick) { setHtml(pick, pickerHtml(list, mounted.got)); pick.value = ''; } const full = panes.length >= MAX; @@ -213,21 +214,34 @@ if (org) p.el.setAttribute('style', ws().colours(org)); else p.el.removeAttribute('style'); const project = thread && i.projects?.find(x => x.id === thread.project)?.name; setHtml(e.chips, `${ws().orgChip(o, p.org)}${ws().projChip(project || '')}`); - let line = '', readable = true; + let line = '', readable = true, why = 'Nothing is read for this thread.'; if (!list) line = 'The organization list could not be read, so sending is off here until it reads again.'; else if (!org) { line = `${p.org} is not on your organization list.`; readable = false; } else if (org.invalid) { line = `${name}: organization invalid. Fix its file under Settings, Organizations.`; readable = false; } - else if (org.busHealthy === false) line = `${name}: bus is ${org.busStopped ? 'stopped' : 'unavailable'}. This pane cannot read or send until it starts again; the other panes are not affected.`; + else if (org.busHealthy === false) { + const how = org.busStopped ? 'stopped' : 'unavailable'; + line = `${name}: bus is ${how}. This pane cannot read or send until it starts again; the other panes are not affected.`; + why = `Nothing is read for this thread while ${name}'s bus is ${how}.`; readable = false; + } else if (i?.threads && !thread) line = `This thread is not in ${name}'s thread list.`; setText(e.line, line); e.line.hidden = !line; if (!readable) { + // 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; + if (typed?.trim()) p.draft = typed; p.chat?.dispose(); p.chat = null; - setHtml(e.body, '

Nothing is read for this thread.

'); + setHtml(e.body, `

${esc(why)}${p.draft ? ' Your unsent message is kept and comes back here when it reads again.' : ''}

`); return; } if (!p.chat) { p.chat = window.MosaicChat.pane(p.org); p.first = true; } - const first = p.first; p.first = false; - void p.chat.render({ path: `/chat/threads/${p.id}`, container: e.body, canSend: !!list && org?.busHealthy !== false && i?.chatSend === true, stopped: org?.busHealthy === false, navigated: first }); + const first = p.first, chat = p.chat; p.first = false; + void chat.render({ path: `/chat/threads/${p.id}`, container: e.body, canSend: !!list && i?.chatSend === true, navigated: first }).then(() => { + const field = p.draft && p.chat === chat ? e.body.querySelector('form textarea') : null; + if (!field) return; + if (!field.value) { field.value = p.draft; field.dispatchEvent(new Event('input', { bubbles: true })); } + p.draft = ''; + }, () => {}); } function paintTabs() { const { root, panes, show } = mounted; @@ -259,7 +273,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 }; + const p = { key, org, id, n: ++paneSeq, el: null, chat: null, first: true, title: null, draft: '' }; const box = mounted.root.querySelector('[data-assist-panes]'); box.insertAdjacentHTML('beforeend', paneShell(p)); p.el = box.lastElementChild; @@ -418,7 +432,7 @@ infos.clear(); void draw({ fresh: true }); } - const hasDraft = () => !!mounted && mounted.panes.some(p => p.chat?.hasDraft()); + const hasDraft = () => !!mounted && mounted.panes.some(p => !!p.draft.trim() || !!p.chat?.hasDraft()); // A write that changed an organization reads it again. document.addEventListener('mosaic-org-changed', e => { if (mounted && mounted.panes.some(p => p.org === e.detail?.org)) { infos.delete(e.detail.org); void draw(); } }); diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js index b178973cf..d0213eb88 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -279,7 +279,7 @@ const focused = $('sections').contains(active) || foot.contains(active) ? active.getAttribute('href') : null; const icon = id => window.MosaicWorkspace?.icon?.(id) ?? ``; const n = (count, what) => count > 0 ? `${count}` : ''; - const items = [['/dashboard', 'Dashboard', '', 'board'], ['/inbox', 'Inbox', n(inboxCount?.open, 'open for you'), 'inbox'], ['/tasks', 'Tasks', '', 'tasks'], ['/projects', 'Projects', '', 'folder'], ['/agents', 'Agents', '', 'agents'], ['/assistant', 'Assistant', n(messageCount, 'unread messages'), 'talk']]; + const items = [['/dashboard', 'Dashboard', '', 'board'], ['/inbox', 'Inbox', n(inboxCount?.open, 'open for you'), 'inbox'], ['/tasks', 'Tasks', '', 'tasks'], ['/projects', 'Projects', '', 'folder'], ['/agents', 'Agents', '', 'agents'], ['/assistant', 'Assistant', '', 'talk']]; const link = ([h, l, extra, id]) => { // #1873 SP1: the Dashboard item is the system dashboard, `#/dashboard`, outside any organization. const cur = h === '/dashboard' ? (route === '/dashboard' || route === '/' || route === '/board') : route.startsWith(h) || (h === '/projects' && route.startsWith('/chat')) || (h === '/tasks' && route.startsWith('/trail/task')) || (h === '/inbox' && (route.startsWith('/trail/decision') || route.startsWith('/trail/message'))); diff --git a/packages/webui/src/public/chat.js b/packages/webui/src/public/chat.js index 9d0e58e0b..a91257013 100644 --- a/packages/webui/src/public/chat.js +++ b/packages/webui/src/public/chat.js @@ -657,7 +657,7 @@ } else { html += heading('Projects') + '

Projects and their conversations.

' + c.projects.map(p => `

${link('projects', p.id, p.name)}

${p.repo ? `

Repository: ${txt(p.repo)}

` : ''}${threads(c.threads.filter(t => t.project === p.id && !t.shared))}
`).join('') + (c.projects.length ? '' : c.readFailed ? '

Projects unavailable.

' : '

No projects yet.

') + '

Shared with me

' + threads(c.threads.filter(t => t.shared)) + creation('project', 'projects'); } - if (!c.canSend && pane) html += c.stopped ? '

This organization\'s bus is stopped. Start it on Settings → Organizations to send messages here.

' : '

Sending is unavailable in this organization. This thread only reads.

'; + if (!c.canSend && pane) html += '

Sending is unavailable in this organization. This thread only reads.

'; else if (!c.canSend) html += window.MosaicOrgBusStopped ? '

The bus is stopped. Use Start the bus on Settings → Organizations to send messages.

' : '

Creating and sending require signed-in send mode. This Console only reads.

'; html += ''; if (pane) html = html.replace(/\b(id|for|aria-controls|aria-describedby|aria-labelledby)="chat-/g, `$1="${env.prefix}`); @@ -768,7 +768,7 @@ err.code === 'thread-not-found' ? 'Thread not found.' : err.code === 'session-not-found' ? 'Session not found in this thread.' : 'Chat could not be read. Refresh to try again; your draft is kept.'; } - async function render({ path, container, canSend, user = false, tester = false, navigated = false, next = false, stopped = false }) { + async function render({ path, container, canSend, user = false, tester = false, navigated = false, next = false }) { if (out()) return; const originalPath = path; const sectionRoute = /^\/projects\/([0-9a-f-]+)\/(settings|conversations|tasks|requirements|files)$/i.exec(path); @@ -789,7 +789,7 @@ paint(); return; } const old = current; - current = { container, kind, id, stopped, orgArchived: old?.orgArchived ?? false, loading: true, archiveUnknown: kind === 'thread' && !user && !tester, settings: !!settings, section, path: originalPath, canSend, user, tester, chatGrants: [], thread: old && old.id === id ? old.thread : null, shared: old && old.id === id ? old.shared : false, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], instances: old?.instances ?? [], instanceError: kind === 'project' ? 'Reading thread instances…' : '', error: '', readFailed: false, channelDisabled: old?.channelDisabled ?? false, refused: false, legacyDefault: old && old.id === id ? old.legacyDefault : false, sessions: old && old.id === id ? old.sessions ?? [] : [], session: match[3] ?? (old && old.id === id ? old.session : null), key: match[3] ? id + '/' + match[3] : old && old.id === id ? old.key : id }; + current = { container, kind, id, orgArchived: old?.orgArchived ?? false, loading: true, archiveUnknown: kind === 'thread' && !user && !tester, settings: !!settings, section, path: originalPath, canSend, user, tester, chatGrants: [], thread: old && old.id === id ? old.thread : null, shared: old && old.id === id ? old.shared : false, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], instances: old?.instances ?? [], instanceError: kind === 'project' ? 'Reading thread instances…' : '', error: '', readFailed: false, channelDisabled: old?.channelDisabled ?? false, refused: false, legacyDefault: old && old.id === id ? old.legacyDefault : false, sessions: old && old.id === id ? old.sessions ?? [] : [], session: match[3] ?? (old && old.id === id ? old.session : null), key: match[3] ? id + '/' + match[3] : old && old.id === id ? old.key : id }; current.projectArchived = current.projects.some(p => p.id === (kind === 'project' ? id : current.thread?.project) && p.archived); if (kind === 'thread' && old?.kind === 'thread' && old.id === id && !navigated && old.archiveUnknown === false && old.projects.some(p => p.id === old.thread?.project)) current.archiveUnknown = false; if (current.projectArchived) current.archiveUnknown = false; diff --git a/packages/webui/src/public/console-restart.js b/packages/webui/src/public/console-restart.js index 74cb1f9a9..259a19fa8 100644 --- a/packages/webui/src/public/console-restart.js +++ b/packages/webui/src/public/console-restart.js @@ -21,7 +21,7 @@ .then(r => (r.type === 'opaqueredirect' ? null : r.ok ? r.json() : null)); const ready = readConfig().then(c => { config = c; boot = typeof c?.boot === 'string' ? c.boot : null; return c; }).catch(() => null); - const dirty = () => !!(window.MosaicChat?.hasDraft?.() || window.MosaicOrgCreate?.hasDraft?.() + const dirty = () => !!(window.MosaicChat?.hasDraft?.() || window.MosaicAssistant?.hasDraft?.() || window.MosaicOrgCreate?.hasDraft?.() || document.querySelector('.settings-dot') || [...document.querySelectorAll('#reply, #conv-reply, #doc-source, #send-body')].some(el => !!el.value?.trim())); diff --git a/packages/webui/src/public/display.js b/packages/webui/src/public/display.js index 44fd10712..23336f874 100644 --- a/packages/webui/src/public/display.js +++ b/packages/webui/src/public/display.js @@ -118,7 +118,7 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date( return read; }; let previous = location.hash, skipGuard = false; - const dirty = () => window.MosaicChat?.hasDraft?.() || window.MosaicOrgCreate?.hasDraft?.() || [...document.querySelectorAll('#reply, #conv-reply, #doc-source')].some(el => !!el.value?.trim()); + const dirty = () => window.MosaicChat?.hasDraft?.() || window.MosaicAssistant?.hasDraft?.() || window.MosaicOrgCreate?.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 dc66bcef0..9644010fd 100644 --- a/packages/webui/tests/assistant-threads-browser.test.mjs +++ b/packages/webui/tests/assistant-threads-browser.test.mjs @@ -52,6 +52,7 @@ function orgs(state) { 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.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')}` }; @@ -76,7 +77,7 @@ async function open(t, { width = 1440, height = 900, hash, state = { down: new S await b.viewport(width, height); const base = `http://127.0.0.1:${server.address().port}/`; await b.navigate(base + hash); - const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`); + 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 }; } @@ -85,6 +86,7 @@ const panes = `[...document.querySelectorAll('[data-assist-pane]')].map(p => p.d 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 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 => { @@ -148,14 +150,14 @@ test('a send in organization B\'s pane reaches B\'s thread while organization A 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(`/Accepted message/.test(${PANE(`beta:${T.b1}`)}.innerText)`); + 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/, 'the other pane shows no receipt'); + 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(`/Accepted message/.test(${PANE(`alpha:${T.a1}`)}.innerText)`); + 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()'); @@ -185,18 +187,41 @@ test('a stopped organization is named at the top, its pane says so, and the othe 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: that pane says so; Alpha's still sends. + // 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(`!${PANE(`beta:${T.b1}`)}.querySelector('form textarea') || ${PANE(`beta:${T.b1}`)}.querySelector('form textarea').disabled`); + 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(`/Accepted message/.test(${PANE(`alpha:${T.a1}`)}.innerText)`); + 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('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 => { @@ -211,7 +236,7 @@ test('the old Side by side address lands on the Assistant page', async t => { }); test('at 390 one pane shows at a time with a tab strip to switch', async t => { - const { b, wait } = await open(t, { width: 390, height: 844, hash: `#/assistant?open=alpha:${T.a1},beta:${T.b1}` }); + 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']]); @@ -219,7 +244,11 @@ test('at 390 one pane shows at a time with a tab strip to switch', async t => { 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'); + 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 => {