From 5dd940165d61deff8c824d9a87755f4461d33c3e Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Nov 2026 04:37:52 -0600 Subject: [PATCH] feat(webui): sp6, the Assistant page holds up to three threads from any organization side by side, with the open set in the address (#1873, lane sp6-threads) The sidebar Assistant opens one system page, #/assistant. Each pane is the chat thread view of its own organization: it reads, sends and updates through that organization's routes, with a header carrying the title, the organization chip, the project chip and a close control. "Open thread" lists the threads of every answering organization, grouped by organization, and starts a new thread in a chosen one. The open set and its order live in the hash (open=org:id,...&show=org:id), validated on read. A stopped organization is named at the top and in its own pane; the other panes keep working. At 390 one pane shows with a tab strip. Side by side is removed (panes.js, panes.css and its test); its old address lands on the Assistant page. --- packages/webui/src/public/assistant.css | 76 +++ packages/webui/src/public/assistant.js | 426 +++++++++++++++ packages/webui/src/public/brand.js | 2 +- packages/webui/src/public/bus.js | 22 +- packages/webui/src/public/chat.js | 172 ++++--- packages/webui/src/public/display.js | 3 +- packages/webui/src/public/index.html | 4 +- packages/webui/src/public/panes.css | 93 ---- packages/webui/src/public/panes.js | 484 ------------------ packages/webui/src/serve.mjs | 4 +- .../tests/assistant-threads-browser.test.mjs | 235 +++++++++ packages/webui/tests/browser.test.mjs | 4 +- .../webui/tests/e2e6-layout-browser.test.mjs | 4 +- .../webui/tests/side-by-side-browser.test.mjs | 432 ---------------- .../tests/unified-workspace-browser.test.mjs | 5 +- 15 files changed, 872 insertions(+), 1094 deletions(-) create mode 100644 packages/webui/src/public/assistant.css create mode 100644 packages/webui/src/public/assistant.js delete mode 100644 packages/webui/src/public/panes.css delete mode 100644 packages/webui/src/public/panes.js create mode 100644 packages/webui/tests/assistant-threads-browser.test.mjs delete mode 100644 packages/webui/tests/side-by-side-browser.test.mjs diff --git a/packages/webui/src/public/assistant.css b/packages/webui/src/public/assistant.css new file mode 100644 index 000000000..f5aaadbea --- /dev/null +++ b/packages/webui/src/public/assistant.css @@ -0,0 +1,76 @@ +/* #1873 SP6: the Assistant page (r2-13). A bar with the page heading and "Open thread", then up to + three thread panes as cards side by side, each with its own header (title, organization and + project chips, close), its own scroll and its own composer. One pane and a tab strip on a phone. + Loaded after shell.css and chat.css; every rule is scoped to the Assistant page. */ +html[data-organizations] .assist{display:flex;flex-direction:column;gap:10px;min-width:0} +html[data-organizations] .assist-bar{display:flex;align-items:flex-start;gap:12px;flex-wrap:wrap} +html[data-organizations] .assist-vh{flex:1 1 320px;min-width:0} +html[data-organizations] .assist-vh h1{margin:0} +html[data-organizations] .assist-vh h1:focus{outline:none} +html[data-organizations] .assist-vh .sub{margin:4px 0 0;color:var(--muted);font-size:14px} +html[data-organizations] .assist-tools{display:flex;align-items:center;gap:8px;flex-wrap:wrap} +html[data-organizations] .assist-open{min-width:220px;max-width:100%;font:500 14px/1.4 var(--font);color:var(--text);background:var(--surface);border:1px solid var(--line);border-radius:var(--r);padding:6px 8px} +html[data-organizations] .assist-open:disabled{color:var(--muted);cursor:not-allowed} +html[data-organizations] .assist [data-assist-unread]:empty,html[data-organizations] .assist [data-assist-note]:empty{display:none} +html[data-organizations] .assist [data-assist-unread] .note{margin:0} +html[data-organizations] .assist [data-assist-note]{margin:0;font-size:13px;color:var(--muted)} + +html[data-organizations] .assist-new{border:1px solid var(--line);border-radius:var(--r);background:var(--surface);padding:12px 14px;display:grid;gap:10px} +html[data-organizations] .assist-new[hidden]{display:none} +html[data-organizations] .assist-new h2{margin:0;font-size:16px} +html[data-organizations] .assist-new h2:focus{outline:none} +html[data-organizations] .assist-new-fields{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px} +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 [data-assist-new-status]{margin:0;font-size:13px} +html[data-organizations] .assist-new [data-assist-new-status]:empty{display:none} + +html[data-organizations] .assist-tabs{display:none;margin-left:0;max-width:100%;flex-wrap:wrap} +html[data-organizations] .assist-tabs>button{max-width:60vw;overflow:hidden;text-overflow:ellipsis;min-height:32px;padding:2px 10px} + +html[data-organizations] .assist-panes{display:grid;grid-template-columns:repeat(var(--assist-n,1),minmax(0,1fr));gap:12px;min-height:0} +html[data-organizations] .assist-panes[hidden]{display:none} +html[data-organizations] .assist-pane{display:flex;flex-direction:column;min-width:0;min-height:0;border:1px solid var(--line);border-radius:var(--r);background:var(--surface);overflow:hidden} +html[data-organizations] .assist-pane-h{display:flex;align-items:center;gap:8px;padding:10px 12px;border-bottom:1px solid var(--line);box-shadow:inset 0 3px 0 var(--org,var(--line));min-width:0;flex-wrap:wrap} +html[data-organizations] .assist-pane-h h2{margin:0;font-size:15px;font-weight:700;color:var(--strong,var(--text));overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1 1 120px} +html[data-organizations] .assist-pane-h h2:focus{outline:none} +html[data-organizations] .assist-chips{display:inline-flex;gap:6px;flex-wrap:wrap;min-width:0} +html[data-organizations] .assist-chips .ws-chip{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +html[data-organizations] .assist .pane-tool{display:inline-grid;place-items:center;width:28px;height:28px;border-radius:var(--r);color:var(--muted);border:0;background:none;padding:0;min-height:0;cursor:pointer;flex:none} +html[data-organizations] .assist .pane-tool:hover{background:var(--raised);color:var(--text)} +html[data-organizations] .assist .pane-tool svg.i{width:15px;height:15px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round} +html[data-organizations] .assist-line{margin:0;padding:6px 12px;border-bottom:1px solid var(--line);font-size:13px;color:var(--text);background:color-mix(in srgb,var(--warn,#b7791f) 12%,var(--surface))} +html[data-organizations] .assist-line[hidden]{display:none} +html[data-organizations] .assist-body{flex:1;min-height:0;overflow:auto;padding:12px;display:flex;flex-direction:column} +/* The chat thread view in a pane: the log scrolls, the composer stays at the foot of the card. + Messages read as the project conversation's bubbles (projects.css scopes those to .project-chat, + whose page-level layout a pane must not take), sized for a third of the screen. */ +html[data-organizations] .assist-body .chat-log{flex:1 1 0;min-height:120px;max-height:none;overflow:auto;margin:8px 0 0;padding:0;border:0} +html[data-organizations] .assist-body .chat-log>li{border:0;display:flex;flex-direction:column;align-items:flex-start;gap:4px;padding-block:6px} +html[data-organizations] .assist-body .chat-log>li.chat-message-human{align-items:flex-end} +html[data-organizations] .assist-body .chat-log>li>.chat-body{background:var(--raised);border:1px solid var(--line);border-radius:12px;padding:8px 12px;max-width:100%;box-sizing:border-box;overflow-wrap:anywhere} +html[data-organizations] .assist-body .chat-log>li.chat-message-human>.chat-body{background:var(--accent-fill);color:var(--on-accent);border-color:transparent} +html[data-organizations] .assist-body .chat-message-meta{display:flex;align-items:center;gap:6px;opacity:1;font-size:12px;color:var(--muted)} +html[data-organizations] .assist-body .chat-message-meta strong{color:var(--text);font-weight:600} +html[data-organizations] .assist-body .chat-avatar{display:grid;place-items:center;flex:none;width:24px;height:24px;border-radius:50%;background:var(--raised);color:var(--action);font-weight:700;font-size:12px} +html[data-organizations] .assist-body .chat-message-human .chat-avatar{background:var(--accent-fill);color:var(--on-accent)} +html[data-organizations] .assist-body>.chat-area{max-width:none;margin:0;flex:1 1 auto;min-height:0;display:flex;flex-direction:column} +html[data-organizations] .assist-body .chat-conversation{flex:1 1 auto;min-height:0;display:flex;flex-direction:column} +html[data-organizations] .assist-body .chat-conversation>p.small{margin:0;font-size:12px} +html[data-organizations] .assist-body form{margin-top:auto;padding-top:10px} +html[data-organizations] .assist-body .chat-conversation>button,html[data-organizations] .assist-body form>button{align-self:flex-start;justify-self:start;width:auto} +html[data-organizations] .assist-body textarea{width:100%;box-sizing:border-box;min-height:56px;height:56px} +html[data-organizations] .assist-empty{margin:24px 0;color:var(--muted)} +html[data-organizations] .assist-empty[hidden]{display:none} + +@media (max-width:760px){ + html[data-organizations] .assist-tabs:not([hidden]){display:inline-flex} + html[data-organizations] .assist-panes{grid-template-columns:minmax(0,1fr);height:auto} + html[data-organizations] .assist-pane.alt{display:none} + html[data-organizations] .assist-body{overflow:visible} + html[data-organizations] .assist-body .chat-log{flex:none;min-height:0;overflow:visible} + html[data-organizations] .assist-open{flex:1 1 100%;min-width:0} + html[data-organizations] .assist-tools{flex:1 1 100%} + html[data-organizations] .assist-new-fields{grid-template-columns:minmax(0,1fr)} +} diff --git a/packages/webui/src/public/assistant.js b/packages/webui/src/public/assistant.js new file mode 100644 index 000000000..d2ad4b5ab --- /dev/null +++ b/packages/webui/src/public/assistant.js @@ -0,0 +1,426 @@ +// #1873 SP6: the Assistant page (#/assistant), one system page with no organization prefix. Up to +// three chat threads, from any organization on the list, open next to each other. Each pane is the +// chat thread view (chat.js) in its own instance pinned to the pane's organization, so its reads and +// its sends name that organization in the address whichever organization the page opened last. +// A phone shows one pane at a time with a tab strip. +// +// The open set, its order and the phone's shown pane live in the address +// (#/assistant?open=:,…&show=:), checked before use and rewritten with +// replaceState. A bare #/assistant opens the set this browser had last. +(() => { + 'use strict'; + if (!document.documentElement.hasAttribute('data-organizations')) return; + const data = window.MosaicWorkspaceData, ws = () => window.MosaicWorkspace; + const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + const enc = encodeURIComponent; + const MAX = 3, POLL = 10000, LIST_TTL = 3000, INFO_TTL = 30000, STORE = 'mosaic-assistant-open-v1'; + const ORG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; + const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + const paused = () => document.getElementById('pause')?.getAttribute('aria-pressed') === 'true'; + const load = () => { try { return localStorage.getItem(STORE); } catch { return null; } }; + const save = value => { try { localStorage.setItem(STORE, value); } catch {} }; + const svg = d => ``; + const CLOSE = svg(''); + const setHtml = (el, html) => { if (el && el.innerHTML !== html) el.innerHTML = html; }; + const setText = (el, text) => { if (el && el.textContent !== text) el.textContent = text; }; + const rowsOf = body => (Array.isArray(body?.rows) ? body.rows : null); + + // ":,…" from the address: valid selectors only, each once, at most three. + function parse(query) { + const q = new URLSearchParams(query || ''), keys = []; + for (const part of (q.get('open') || '').split(',')) { + const cut = part.indexOf(':'); + if (cut < 1) continue; + const org = part.slice(0, cut), id = part.slice(cut + 1).toLowerCase(); + if (!ORG.test(org) || !UUID.test(id)) continue; + const key = `${org}:${id}`; + if (!keys.includes(key) && keys.length < MAX) keys.push(key); + } + const show = q.get('show'); + 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) }; }; + + // ---------- reads ---------- + // The organization list, and per organization its threads, projects and whether this Console may + // send there (its config's chatSend). An organization whose bus is down is not read. + let listAt = 0, listPromise = null; + const infos = new Map(); + function orgList(fresh = false) { + if (!listPromise || fresh || Date.now() - listAt > LIST_TTL) { + listAt = Date.now(); + listPromise = Promise.all([data.organizations(), ws()?.themes?.()]).then(([rows]) => (Array.isArray(rows) ? rows : null)).catch(() => null); + } + return listPromise; + } + const readConfig = org => fetch(`/api/orgs/${enc(org)}/config`, { cache: 'no-store', redirect: 'manual' }) + .then(r => (r.ok ? r.json() : null)).then(c => c?.chatSend === true).catch(() => false); + function info(org, fresh = false) { + if (!org || org.invalid || org.busHealthy === false) return Promise.resolve({ threads: null, projects: null, chatSend: false, read: false }); + const hit = infos.get(org.id); + if (hit && !fresh && Date.now() - hit.at < INFO_TTL) return hit.promise; + const promise = Promise.all([ + data.threads(org.id).then(rowsOf).catch(() => null), + data.projects(org.id).then(rowsOf).catch(() => null), + readConfig(org.id), + ]).then(([threads, projects, chatSend]) => ({ + threads: threads && threads.filter(t => t && !t.shared && typeof t.id === 'string' && UUID.test(t.id)), + projects: projects && projects.filter(p => p && typeof p.id === 'string'), + chatSend, read: true, + })); + infos.set(org.id, { at: Date.now(), promise }); + return promise; + } + // The rows ws().orgsOf reads: an organization not ok is named at the top of the page. + function orgRows(list, got) { + return list.map(o => { + const why = o.invalid ? ['invalid', o.reason || ''] : o.busHealthy === false ? ['down', ws().state(o)[2]] : got.get(o.id)?.threads === null ? ['timeout', 'the thread list did not answer'] : ['ok', '']; + return { org: o.id, label: o.label, theme: o.theme, state: why[0], reason: why[1] }; + }); + } + + // ---------- state ---------- + let mounted = null; // { root, panes: [pane], show, turn, timer, list, got, o, form } + let mountGen = 0, paneSeq = 0; + + function writeUrl() { + if (!mounted) return; + const keys = mounted.panes.map(p => p.key); + const qs = keys.length ? `open=${keys.join(',')}${mounted.show && mounted.show !== keys[0] ? `&show=${mounted.show}` : ''}` : ''; + save(keys.join(',')); + const next = qs ? `#/assistant?${qs}` : '#/assistant'; + if (location.hash !== next) history.replaceState(null, '', next); + } + + // ---------- markup ---------- + function frame() { + return `
+
+

Assistant

+

Threads from any organization, side by side. Each pane reads and sends through its own organization.

+
+ + + Messages +
+
+
+

+ +
+
+ +
`; + } + function paneShell(p) { + const n = p.n; + return `
+
+

Reading…

+ + +
+ +
+
`; + } + const els = p => ({ + title: p.el.querySelector('[data-assist-title]'), chips: p.el.querySelector('[data-assist-chips]'), + close: p.el.querySelector('[data-assist-close]'), line: p.el.querySelector('[data-assist-line]'), body: p.el.querySelector('[data-assist-body]'), + }); + + // The picker: every organization on the list, its threads, and "New thread in …" where this + // Console may send. A stopped, invalid or silent organization is named and offers nothing. + function pickerHtml(list, got) { + const open = new Set(mounted.panes.map(p => p.key)); + const groups = (list || []).filter(o => o && typeof o.id === 'string' && ORG.test(o.id)).map(o => { + const name = ws().label(o), i = got.get(o.id); + if (o.invalid) return ``; + if (o.busHealthy === false) return ``; + if (!i?.threads) return ``; + const project = id => i.projects?.find(p => p.id === id)?.name; + const rows = i.threads.map(t => { + const key = `${o.id}:${t.id.toLowerCase()}`, where = project(t.project); + return ``; + }).join(''); + const fresh = i.chatSend ? `` : ''; + return `${rows || (fresh ? '' : '')}${fresh}`; + }); + return `${groups.join('')}`; + } + + // ---------- drawing ---------- + async function draw({ fresh = false, navigated = false } = {}) { + if (!mounted) return; + const gen = mountGen, turn = ++mounted.turn; + const live = () => mounted && gen === mountGen && turn === mounted.turn; + clearTimeout(mounted.timer); + const list = await orgList(fresh); + if (!live()) return; + const openOrgs = new Set(mounted.panes.map(p => p.org)); + const valid = (list || []).filter(o => o && typeof o.id === 'string' && ORG.test(o.id)); + const got = new Map(await Promise.all(valid.map(async o => [o.id, await info(o, fresh || (!navigated && openOrgs.has(o.id)))]))); + if (!live()) return; + mounted.list = list; mounted.got = got; + mounted.o = ws().orgsOf({ orgs: orgRows(valid, got) }); + paintTop(); + for (const p of mounted.panes) drawPane(p); + paintTabs(); layout(); + mounted.timer = setTimeout(tick, POLL); + } + function tick() { + if (!mounted) return; + if (paused()) { mounted.timer = setTimeout(tick, POLL); return; } + void draw(); + } + 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` : ''); + const pick = root.querySelector('[data-assist-open]'); + if (document.activeElement !== pick) { setHtml(pick, pickerHtml(list, mounted.got)); pick.value = ''; } + const full = panes.length >= MAX; + pick.disabled = full; + pick.title = full ? `${MAX} threads are open. Close one to open another.` : ''; + const here = window.MosaicOrg?.id, messages = root.querySelector('[data-assist-messages]'); + messages.hidden = !here; + if (here) { + const href = `#/orgs/${enc(here)}/messages`; + if (messages.getAttribute('href') !== href) messages.setAttribute('href', href); + setText(messages, `Messages in ${window.MosaicOrg.label || here}`); + } + root.querySelector('[data-assist-empty]').hidden = panes.length > 0; + } + function drawPane(p) { + if (!mounted || !p.el.isConnected) return; + const { list, got, o } = mounted, e = els(p); + const org = list?.find(x => x?.id === p.org) || null, i = got.get(p.org); + const name = org ? ws().label(org) : p.org; + const thread = i?.threads?.find(t => t.id.toLowerCase() === p.id) || null; + const title = thread?.name || `Thread ${p.id.slice(0, 8)}`; + p.title = title; + setText(e.title, title); + e.close.setAttribute('aria-label', `Close ${title}, ${name}`); + 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; + 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 (i?.threads && !thread) line = `This thread is not in ${name}'s thread list.`; + setText(e.line, line); e.line.hidden = !line; + if (!readable) { + p.chat?.dispose(); p.chat = null; + setHtml(e.body, '

Nothing is read for this thread.

'); + 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 }); + } + function paintTabs() { + const { root, panes, show } = mounted; + const tabs = root.querySelector('[data-assist-tabs]'); + const focused = tabs.contains(document.activeElement) ? document.activeElement.dataset.assistShow : null; + const html = panes.map(p => ``).join(''); + if (tabs.innerHTML !== html) { tabs.innerHTML = html; if (focused) tabs.querySelector(`[data-assist-show="${CSS.escape(focused)}"]`)?.focus({ preventScroll: true }); } + tabs.hidden = panes.length < 2; + } + function layout() { + const { root, panes, show } = mounted; + const box = root.querySelector('[data-assist-panes]'); + box.style.setProperty('--assist-n', String(Math.max(1, panes.length))); + box.hidden = !panes.length; + for (const p of panes) p.el.classList.toggle('alt', show !== p.key); + fit(); + } + // Wide screens: the panes fill the window below the bar and each scrolls on its own. + const wide = window.matchMedia('(min-width: 761px)'); + function fit() { + if (!mounted) return; + const box = mounted.root.querySelector('[data-assist-panes]'); + if (!wide.matches) { box.style.height = ''; return; } + const foot = document.querySelector('body > .foot, .foot')?.getBoundingClientRect().height || 0; + const top = box.getBoundingClientRect().top; + box.style.height = `${Math.max(360, Math.floor(window.innerHeight - Math.max(top, 0) - foot - 16))}px`; + } + + // ---------- 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 box = mounted.root.querySelector('[data-assist-panes]'); + box.insertAdjacentHTML('beforeend', paneShell(p)); + p.el = box.lastElementChild; + return p; + } + function addPane(key, { focus = false } = {}) { + if (!mounted) return; + const had = mounted.panes.find(p => p.key === key); + if (had) { mounted.show = key; paintTabs(); layout(); writeUrl(); return; } + if (mounted.panes.length >= MAX) return; + const p = makePane(key); + mounted.panes.push(p); mounted.show = key; + writeUrl(); + if (mounted.got) { paintTop(); drawPane(p); paintTabs(); layout(); } + if (focus) els(p).title.setAttribute('tabindex', '-1'), els(p).title.focus({ preventScroll: true }); + } + function closePane(p) { + if (!mounted || !p) return; + const at = mounted.panes.indexOf(p); + if (at < 0) return; + p.chat?.dispose(); p.chat = null; p.el.remove(); + mounted.panes.splice(at, 1); + const next = mounted.panes[Math.min(at, mounted.panes.length - 1)]; + if (mounted.show === p.key) mounted.show = next?.key ?? null; + writeUrl(); paintTop(); paintTabs(); layout(); + // The close button just left the page: focus the next pane's close control, or the picker. + (next ? els(next).close : mounted.root.querySelector('[data-assist-open]')).focus({ preventScroll: true }); + } + + // ---------- a new thread ---------- + async function openForm(orgId) { + if (!mounted) return; + const org = mounted.list?.find(o => o?.id === orgId); + if (!org || org.busHealthy === false || mounted.got.get(orgId)?.chatSend !== true) return; + const f = mounted.root.querySelector('[data-assist-new]'), formTurn = ++mounted.formTurn; + mounted.form = { org: orgId, busy: false }; + f.hidden = false; f.reset(); + setText(f.querySelector('[data-assist-new-org]'), ws().label(org)); + setText(f.querySelector('[data-assist-new-status]'), 'Reading projects and agents…'); + const project = f.elements.project, instance = f.elements.instance; + project.innerHTML = ''; instance.innerHTML = ''; project.disabled = true; instance.disabled = true; + f.querySelector('h2').focus({ preventScroll: true }); + const [i, agents] = await Promise.all([ + info(org, true), + fetch(`/api/orgs/${enc(orgId)}/chat/instances`, { cache: 'no-store', redirect: 'manual' }).then(r => (r.ok ? r.json() : null)).then(rowsOf).catch(() => null), + ]); + if (!mounted || mounted.formTurn !== formTurn || f.hidden) return; + const projects = (i.projects || []).filter(p => !p.archived); + project.innerHTML = projects.map(p => ``).join(''); + instance.innerHTML = (agents || []).filter(a => a && typeof a.instance === 'string').map(a => ``).join(''); + project.disabled = !projects.length; instance.disabled = !agents?.length; + const missing = [i.projects === null ? 'Projects could not be read.' : !projects.length ? 'This organization has no open project.' : '', agents === null ? 'Agents could not be read.' : !agents.length ? 'No agent takes threads here.' : ''].filter(Boolean).join(' '); + setText(f.querySelector('[data-assist-new-status]'), missing); + } + function closeForm() { + if (!mounted) return; + mounted.form = null; mounted.formTurn++; + mounted.root.querySelector('[data-assist-new]').hidden = true; + } + async function submitForm(f) { + const form = mounted?.form; + if (!form || form.busy) return; + const status = f.querySelector('[data-assist-new-status]'); + const body = { project: f.elements.project.value, name: f.elements.name.value.trim(), instance: f.elements.instance.value }; + if (!body.project || !body.instance || !body.name) { setText(status, 'Choose a project and an agent, and name the thread.'); return; } + form.busy = true; + const submit = f.querySelector('[type=submit]'); submit.disabled = true; + setText(status, 'Starting the thread…'); + let result = null, why = ''; + try { + const r = await fetch(`/api/orgs/${enc(form.org)}/chat/threads`, { method: 'POST', cache: 'no-store', redirect: 'manual', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); + const v = await r.json().catch(() => null); + if (r.ok && typeof v?.id === 'string' && UUID.test(v.id)) result = v.id.toLowerCase(); + else why = r.type === 'opaqueredirect' || r.status === 401 ? 'You are signed out. Reload the page to sign in again.' : `The thread was not started${typeof v?.error === 'string' ? ` (${v.error})` : ''}.`; + } catch { why = 'Outcome unknown. Look under Open thread before trying again.'; } + if (mounted?.form !== form) return; + form.busy = false; submit.disabled = false; + if (!result) { setText(status, why); return; } + infos.delete(form.org); + const key = `${form.org}:${result}`; + if (mounted.panes.length >= MAX && !mounted.panes.some(p => p.key === key)) { setText(status, `Started. ${MAX} threads are open; close one to open it.`); return; } + closeForm(); + addPane(key, { focus: true }); + void draw({ fresh: false }); + } + + // ---------- events (bound once to the mount root) ---------- + function bind(root) { + root.addEventListener('change', e => { + const pick = e.target.closest?.('[data-assist-open]'); + if (!pick || !mounted) return; + const value = pick.value; + pick.value = ''; + if (value.startsWith('new:')) { void openForm(value.slice(4)); return; } + const want = parse(`open=${value}`).keys[0]; + if (want) addPane(want); + }); + root.addEventListener('click', e => { + if (!mounted) return; + const show = e.target.closest?.('[data-assist-show]'); + if (show) { const p = mounted.panes.find(x => x.key === show.dataset.assistShow); if (p) { mounted.show = p.key; paintTabs(); layout(); writeUrl(); } return; } + const close = e.target.closest?.('[data-assist-close]'); + if (close) { closePane(mounted.panes.find(x => x.el.contains(close))); return; } + if (e.target.closest?.('[data-assist-new-cancel]')) { closeForm(); root.querySelector('[data-assist-open]').focus({ preventScroll: true }); } + }); + root.addEventListener('submit', e => { + const f = e.target.closest?.('[data-assist-new]'); + if (!f) return; + e.preventDefault(); + void submitForm(f); + }); + } + + // ---------- public ---------- + async function mount(container, query, { navigated = false } = {}) { + unmount(); + ++mountGen; + container.innerHTML = frame(); + const root = container.querySelector('[data-assistant]'); + mounted = { root, panes: [], show: null, turn: 0, timer: null, list: null, got: null, o: null, form: null, formTurn: 0 }; + bind(root); + window.addEventListener('resize', fit); + document.title = 'Assistant · Mosaic Console'; + if (navigated) root.querySelector('h1').focus({ preventScroll: true }); + let want = parse(query); + if (!want.named) want = parse(`open=${load() || ''}`); + for (const key of want.keys) mounted.panes.push(makePane(key)); + mounted.show = want.show || want.keys[0] || null; + writeUrl(); paintTabs(); layout(); + root.querySelector('[data-assist-empty]').hidden = mounted.panes.length > 0; + await draw({ navigated: true }); + } + function unmount() { + if (!mounted) return; + clearTimeout(mounted.timer); + for (const p of mounted.panes) p.chat?.dispose(); + window.removeEventListener('resize', fit); + mounted = null; mountGen++; + } + // The Console's Refresh, and a redraw of the same page: every pane reads again now. A query that + // names a different open set (an address edited in place) opens that set. + function refresh(query) { + if (!mounted) return; + 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(); } + 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]'); + for (const p of mounted.panes) box.append(p.el); + mounted.show = want.show || want.keys[0] || null; + writeUrl(); + } + } + infos.clear(); + void draw({ fresh: true }); + } + const hasDraft = () => !!mounted && mounted.panes.some(p => 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(); } }); + + window.MosaicAssistant = Object.freeze({ mount, unmount, refresh, hasDraft }); +})(); diff --git a/packages/webui/src/public/brand.js b/packages/webui/src/public/brand.js index 37e51994e..90f67cdde 100644 --- a/packages/webui/src/public/brand.js +++ b/packages/webui/src/public/brand.js @@ -197,7 +197,7 @@ if (parts[0] === 'board' && /^#\/orgs\//.test(location.hash)) { box.innerHTML = 'Control board'; return; } if (!parts.length || parts[0] === 'board' || parts[0] === 'dashboard') { box.innerHTML = 'Dashboard'; return; } if (parts[0] === 'organization') { const h = $('bus-view').querySelector('[data-org-dashboard] h1')?.textContent?.trim(); box.innerHTML = `Dashboard${h ? `${escape(h)}` : ''}`; return; } - const names = {projects:'Projects',chat:'Projects',inbox:'Inbox',messages:'Messages',tasks:'Tasks',agents:'Agents',queue:'Queue',business:'Business',docs:'Docs',settings:'Settings'}; + const names = {projects:'Projects',chat:'Projects',inbox:'Inbox',messages:'Messages',assistant:'Assistant',tasks:'Tasks',agents:'Agents',queue:'Queue',business:'Business',docs:'Docs',settings:'Settings'}; if (parts[0] === 'settings' && parts[1] === 'business' && $('bus-view').querySelector('#settings-page-title')?.textContent.trim() === 'Business') { box.innerHTML = 'SettingsBusiness'; return; } const name = names[parts[0]] || 'Dashboard', heading = $('bus-view').querySelector('h1')?.textContent?.trim(); const project = $('bus-view').querySelector('.chat-conversation > p:first-child a, .chat-room > p:first-child a'); diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js index 4548b3386..b178973cf 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -268,8 +268,8 @@ // ---------------------------------------------------------------- views let inboxCount = null, messageCount = null; - // Organization mode (sample 01): Dashboard, Inbox, Tasks, Projects, Side by side, Agents and - // Assistant, with Settings pinned to the foot of the sidebar. Queue opens from the organization + // Organization mode (sample 01): Dashboard, Inbox, Tasks, Projects, Agents and Assistant (SP6: + // `#/assistant`, threads of any organization side by side), with Settings pinned to the foot of the sidebar. Queue opens from the organization // dashboard and Docs from its Docs row, so neither is a sidebar item here. function orgSections(route) { const nav = $('console-nav'); @@ -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'], ['/side-by-side', 'Side by side', '', 'columns'], ['/agents', 'Agents', '', 'agents'], ['/messages', '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', n(messageCount, 'unread messages'), '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'))); @@ -1809,13 +1809,17 @@ window.MosaicChat?.leave(chatPath); if (path === '/business') { location.replace('#/settings/business'); return; } if (path === '/settings/display') { location.replace('#/settings/appearance'); return; } - // U5: Side by side draws its own two panes (panes.js); every other page stops their polling. - if (path !== '/side-by-side') window.MosaicPanes?.unmount(); - else if (window.MosaicOrg && window.MosaicPanes) { + // #1873 SP6: the Assistant page draws its own thread panes (assistant.js); every other page + // stops their polling. It is a system page: an organization's address for it, and the old + // Side by side address, land on `#/assistant`. + if (window.MosaicOrg && window.MosaicAssistant && path === '/side-by-side') { location.replace('#/assistant'); return; } + if (path !== '/assistant') window.MosaicAssistant?.unmount(); + else if (window.MosaicOrg && window.MosaicAssistant) { + if (!window.MosaicOrg.system()) { location.replace(`#/assistant${query ? `?${query}` : ''}`); return; } const fresh = navigated || shown !== path; shown = path; - if (fresh) { unmountEditor(); await window.MosaicPanes.mount($('bus-view'), query, { navigated }); } - else window.MosaicPanes.refresh(); + if (fresh) { unmountEditor(); await window.MosaicAssistant.mount($('bus-view'), query, { navigated }); } + else window.MosaicAssistant.refresh(query); return; } if (chatPath === '/chat' || chatPath.startsWith('/chat/') || /^\/projects\/[0-9a-f-]+\/(?:settings|conversations|tasks|requirements|files)$/i.test(chatPath)) { @@ -1980,7 +1984,7 @@ // Ctrl+K opens it. It lists the views, and the decisions and tasks from the last inbox and // tasks reads (opening it reads both again, GET only). Enter goes; Esc closes and puts focus // back where it was. It only navigates. - const VIEWS = [['#/', 'Dashboard'], ['#/projects', 'Projects'], ['#/inbox', 'Inbox'], ['#/messages', 'Assistant'], ['#/tasks', 'Tasks'], ['#/agents', 'Agents'], ['#/queue', 'Queue'], ['#/settings/business', 'Business'], ['#/docs', 'Docs'], ['#/settings', 'Settings'], ['#/settings/display', 'Sidebar display']]; + const VIEWS = [['#/', 'Dashboard'], ['#/projects', 'Projects'], ['#/inbox', 'Inbox'], ...(window.MosaicAssistant ? [['#/assistant', 'Assistant'], ['#/messages', 'Messages']] : [['#/messages', 'Assistant']]), ['#/tasks', 'Tasks'], ['#/agents', 'Agents'], ['#/queue', 'Queue'], ['#/settings/business', 'Business'], ['#/docs', 'Docs'], ['#/settings', 'Settings'], ['#/settings/display', 'Sidebar display']]; let pkAll = [], pkShown = [], pkAt = 0, pkBack = null; function pkItems() { const decisions = (last.get('inbox')?.rows || []).map(d => ({ href: `#/inbox/${enc(d.id)}`, label: first(d.question), group: `decision ${short(d.id)}` })); diff --git a/packages/webui/src/public/chat.js b/packages/webui/src/public/chat.js index 663e1db21..9d0e58e0b 100644 --- a/packages/webui/src/public/chat.js +++ b/packages/webui/src/public/chat.js @@ -1,6 +1,11 @@ /* Row 129: business chat, isolated from the Board's seat conversations and role composer. */ (() => { 'use strict'; + // #1873 SP6: one chat view, more than one instance. The page's own instance (no env) is the chat + // as it always was. An Assistant pane makes its own instance with env.org: its reads and writes + // name that organization in the address, so a send always reaches the pane's own organization, + // whichever organization the page last opened, and its listeners answer only inside its container. + function chatInstance(env = {}) { const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; // crypto.randomUUID exists only in a secure context; a dev owner Console opened by LAN name // (http://NAME:PORT/) is not one, so build the same v4 form from getRandomValues there. @@ -18,6 +23,14 @@ // E2E6 P2-15: a message's `inline code` spans show as code, not as raw backticks. The text is escaped // first; a pair of single backticks on one line then wraps its already-escaped contents in . const prose = value => txt(value).replace(/`([^`\n]+)`/g, '$1'); + const pane = typeof env.org === 'string', life = new AbortController(); + const on = (type, fn, capture = false) => document.addEventListener(type, fn, { capture, signal: life.signal }); + // A pane prefixes its element ids so two panes never share one; handlers read them back unprefixed. + const idOf = el => { const id = typeof el?.id === 'string' ? el.id : ''; return pane && id.startsWith(env.prefix) ? 'chat-' + id.slice(env.prefix.length) : id; }; + // A pane's ids carry its prefix; byId takes the page's own 'chat-…' name. + const byId = id => document.getElementById(pane && id.startsWith('chat-') ? env.prefix + id.slice(5) : id); + const pid = id => (pane && id.startsWith('chat-') ? env.prefix + id.slice(5) : id); + const mine = target => pane ? !!current?.container.contains(target) : !target?.closest?.('[data-assist-pane]'); const states = new Map(), forms = new Map(), knownRequests = new Map(); const pinnedScrolls = new WeakMap(), settlingPins = new WeakMap(); const pageFlow = log => log.closest('.project-chat') && matchMedia('(max-width: 319px), (max-height: 540px)').matches; @@ -27,7 +40,7 @@ ? (pageTailTarget(log)?.getBoundingClientRect().bottom ?? 0) - innerHeight < 40 || messageEnd && Math.abs((log.lastElementChild?.getBoundingClientRect().bottom ?? 0) - innerHeight) < 40 : log.scrollHeight - log.scrollTop - log.clientHeight < 40; const pageTailTarget = log => { - const form = log.closest('.project-chat')?.querySelector('#chat-send-form, #chat-room-send-form'); + const form = log.closest('.project-chat')?.querySelector(`#${pid('chat-send-form')}, #${pid('chat-room-send-form')}`); const field = form?.querySelector('textarea'); const usable = current && (!field?.disabled && !current.channelDisabled || state(current.key ?? current.id).pending); return form && field && usable && form.getClientRects().length ? form : log.lastElementChild; @@ -69,8 +82,7 @@ const channelMessage = 'The chat channel is disabled for this business. Ask the person who manages it to enable WebUI chat, then refresh.'; function forget() { generation++; controller?.abort(); states.clear(); forms.clear(); knownRequests.clear(); libraries.clear(); reverts.clear(); - window.MosaicRequests?.forget(); - window.MosaicProjectArchive?.forget(); + if (!pane) { window.MosaicRequests?.forget(); window.MosaicProjectArchive?.forget(); } if (current) current.container.replaceChildren(); current = null; } @@ -82,7 +94,7 @@ if (out()) throw new ChatError('signed-out'); let res; try { - res = await fetch('/api/chat/' + path, { + res = await fetch((pane ? `/api/orgs/${encodeURIComponent(env.org)}/chat/` : '/api/chat/') + path, { cache: 'no-store', redirect: 'manual', signal, ...(value === undefined ? {} : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(value) }), }); @@ -104,12 +116,14 @@ } // In organization mode #/projects is the cross-organization page, so the organization's own // project list lives at #/chat. That list keeps the project surface and its #/projects links. - const legacyRoute = () => { const r = window.MosaicOrg?.route() ?? location.hash.slice(1); return r.startsWith('/chat') && !(window.MosaicOrg && r.split('?')[0] === '/chat'); }; - const projectPath = id => legacyRoute() ? `#/chat/projects/${id}` : `#/projects/${id}`; - const threadPath = id => legacyRoute() ? `#/chat/threads/${id}` : `#/projects/threads/${id}`; - const roomPath = id => legacyRoute() ? `#/chat/rooms/${id}` : `#/projects/rooms/${id}`; + const orgBase = pane ? '#/orgs/' + encodeURIComponent(env.org) : '#'; + const legacyRoute = () => { if (pane) return false; const r = window.MosaicOrg?.route() ?? location.hash.slice(1); return r.startsWith('/chat') && !(window.MosaicOrg && r.split('?')[0] === '/chat'); }; + const projectPath = id => legacyRoute() ? `#/chat/projects/${id}` : `${orgBase}/projects/${id}`; + const threadPath = id => legacyRoute() ? `#/chat/threads/${id}` : `${orgBase}/projects/threads/${id}`; + const roomPath = id => legacyRoute() ? `#/chat/rooms/${id}` : `${orgBase}/projects/rooms/${id}`; const link = (type, id, label) => UUID.test(id) ? `${txt(label)}` : txt(label); - const heading = text => `

${txt(text)}

`; + // A pane's title is its own header; the page keeps its one h1. + const heading = text => pane ? `

${txt(text)}

` : `

${txt(text)}

`; const selectedInactive = () => { const s = current?.sessions?.find(r => r.id === current.session); return !!s?.state && s.state !== 'active'; }; const disabledSession = pending => disabled(pending || selectedInactive()); const disabled = pending => !current.canSend || current.orgArchived || out() || pending ? ' disabled' : ''; @@ -199,7 +213,8 @@ return `

${parts.join(' · ')}

${list}
`; } async function loadLibrary(project, g, signal) { - if (project === null) return; + // The library routes read the page's organization, not a pane's: a pane shows no library line. + if (project === null || pane) return; let entry; try { const res = await fetch('/api/library/status?project=' + encodeURIComponent(project), { cache: 'no-store', redirect: 'manual', signal }); @@ -241,6 +256,8 @@ const REQUEST_ROWS = 3; // The request panel, Files and summary each read independently. Share only confirmed // request-not-found responses while a thread is open, without changing other fetch paths. + // Panes mount no request panels, so only the page's instance wraps fetch. + if (!pane) { const fetchPage = window.fetch.bind(window); window.fetch = async (url, init) => { const path = typeof url === 'string' && /^\/api\/requests\/([A-Za-z0-9][A-Za-z0-9._-]{0,159})(?:\/workspace)?$/.exec(url); @@ -260,11 +277,12 @@ } return response; }; + } // A request read elsewhere on this page (any requests.js panel) proves the id now exists, so // every open thread forgets that miss and the next refresh reads it again. The id's own // generation also advances, so a 404 still in flight from before this observation cannot // record the miss after it. Only that id moves; other ids' slow misses still settle. - document.addEventListener('mosaic-request-state', event => { + on('mosaic-request-state', event => { const id = event.detail?.state?.request; if (typeof id !== 'string' || out()) return; for (const s of states.values()) { @@ -274,7 +292,7 @@ }); function requestRows(rows) { const ids = new Set(); - if (!window.MosaicRequests || !roomOwner()) return ids; + if (pane || !window.MosaicRequests || !roomOwner()) return ids; for (let i = rows.length - 1; i >= 0 && ids.size < REQUEST_ROWS; i--) { const m = rows[i]; if (m.from_role === 'human' && typeof m.request === 'string' && REQUEST_ID.test(m.request)) ids.add(m.id); @@ -286,7 +304,7 @@ const withRequest = requestRows(s.rows); const receipt = s.receipt; const project = current.projects.find(p => p.id === t.project); - const redesigned = project && roomOwner() && window.MosaicProjects && (!legacyRoute() || current.path?.startsWith('/projects')); + const redesigned = !pane && project && roomOwner() && window.MosaicProjects && (!legacyRoute() || current.path?.startsWith('/projects')); // Keep each request tied to its originating message while placing its card after the first reply. const mountsAfter = new Map(); if (redesigned) s.rows.forEach((m, i) => { @@ -297,9 +315,11 @@ }); const mount = m => `
`; const messages = s.rows.map((m, i) => `
  • ${txt(m.from_role === 'human' ? 'You' : m.from_role)} ${stamp(m.at)}
    ${messageBody(m)}${!redesigned && withRequest.has(m.id) ? mount(m) : ''}
  • ${redesigned ? (mountsAfter.get(i) || []).map(origin => `
  • ${mount(origin)}
  • `).join('') : ''}`).join(''); - const frame = redesigned ? window.MosaicProjects.header(project, 'conversations', current.threads, t.name, true) : heading(t.name); - const details = redesigned ? `

    Conversation details

    ${sessionControls(t)}${sharing(t)}

    Reading never wakes the assistant. An accepted message is not an answer.

    ${threadWrites() ? `` : ''}
    ` : `
    Conversation details${sessionControls(t)}${sharing(t)}

    Reading never wakes the assistant. An accepted message is not an answer.

    ${threadWrites() ? `` : ''}`; - return `

    ${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}

    ${frame}${libraryLine(libraryOf(current, t))}${details}${redesigned ? '

    ' : ''}
      ${messages || (current.readFailed ? '
    1. Messages unavailable.
    2. ' : '
    3. No messages yet.
    4. ')}
    ${current.readFailed ? '' : s.more ? '

    Showing earlier history. Read forward to reach the current conversation.

    ' : '

    Caught up to the last read.

    '}
    ${threadWrites() ? `

    To: ${redesigned ? `${txt(t.instance)}` : `${txt(t.instance)} · Private conversation`}

    ${redesigned ? '

    Enter adds a new line.

    ' : ''}
    ` : ''}

    ${txt(receipt)}

    ${redesigned ? '' : '

    Files

    Files unavailable.
    '}
    `; + const frame = pane ? '' : redesigned ? window.MosaicProjects.header(project, 'conversations', current.threads, t.name, true) : heading(t.name); + // A pane leaves out the details: sessions and sharing are unavailable on an organization Console. + const resume = threadWrites() ? `` : ''; + const details = pane ? `

    Reading never wakes the assistant. An accepted message is not an answer.

    ` : redesigned ? `

    Conversation details

    ${sessionControls(t)}${sharing(t)}

    Reading never wakes the assistant. An accepted message is not an answer.

    ${threadWrites() ? `` : ''}
    ` : `
    Conversation details${sessionControls(t)}${sharing(t)}

    Reading never wakes the assistant. An accepted message is not an answer.

    ${threadWrites() ? `` : ''}`; + return `
    ${pane ? '' : `

    ${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}

    `}${frame}${libraryLine(libraryOf(current, t))}${details}${redesigned ? '

    ' : ''}
      ${messages || (current.readFailed ? '
    1. Messages unavailable.
    2. ' : '
    3. No messages yet.
    4. ')}
    ${current.readFailed ? '' : s.more ? '

    Showing earlier history. Read forward to reach the current conversation.

    ' : '

    Caught up to the last read.

    '}
    ${threadWrites() ? `

    To: ${redesigned ? `${txt(t.instance)}` : `${txt(t.instance)} · Private conversation`}

    ${redesigned ? '

    Enter adds a new line.

    ' : ''}
    ` : ''}

    ${txt(receipt)}

    ${pane ? resume : ''}${redesigned || pane ? '' : '

    Files

    Files unavailable.
    '}
    `; } // C2-10c: rooms reuse the same state, renderer and generation fences as threads. const ROOM_LINES = Object.freeze({ @@ -475,19 +495,20 @@ if (err.code === 'channel-disabled' && current?.key === c.key) current.channelDisabled = true; } finally { s.pending = false; if (current?.key === c.key && !out()) paint(); } } - document.addEventListener('keydown', event => { - if (current?.kind !== 'room' || event.target.id !== 'chat-room-body' || !current.completion?.items.length) return; + on('keydown', event => { + if (!mine(event.target)) return; + if (current?.kind !== 'room' || idOf(event.target) !== 'chat-room-body' || !current.completion?.items.length) return; const c = current.completion; if (['ArrowDown', 'ArrowUp'].includes(event.key)) { event.preventDefault(); c.index = (c.index + (event.key === 'ArrowDown' ? 1 : c.items.length - 1)) % c.items.length; drawRoomCompletion(); } if (['Enter', 'Tab'].includes(event.key)) { event.preventDefault(); completeRoom(c.items[c.index].instance); } if (event.key === 'Escape') { event.preventDefault(); current.completion = null; drawRoomCompletion(); } }); - document.addEventListener('mousedown', event => { if (event.target.closest?.('[data-room-complete]')) event.preventDefault(); }); + on('mousedown', event => { if (event.target.closest?.('[data-room-complete]')) event.preventDefault(); }); function capture() { if (!current) return null; const active = document.activeElement, log = current.container.querySelector('.chat-log'); - const field = active?.id?.startsWith('chat-') ? { id: active.id, start: active.selectionStart, end: active.selectionEnd } : null; + const field = idOf(active).startsWith('chat-') && current.container.contains(active) ? { id: active.id, start: active.selectionStart, end: active.selectionEnd } : null; const dialog = current.container.querySelector('dialog.project-dialog[open]'); const dialogValues = dialog ? [...dialog.querySelectorAll('input[id],select[id],textarea[id]')].map(e=>({id:e.id,value:e.value,checked:e.checked})) : []; const dialogFocus = dialog?.contains(active) ? { id:active.id, index:[...dialog.querySelectorAll('button,a,input,select,textarea,[tabindex]')].indexOf(active), value:'value' in active ? active.value : null, start:active.selectionStart, end:active.selectionEnd } : null; @@ -512,7 +533,7 @@ // Refreshes often read exactly the same view. Remember the generated presentation, // not the mutable DOM (which also contains independently mounted request controls). function refreshProjectData(c, redesigned, restore) { - if (c.user || c.tester || !window.MosaicProjects) return; + if (pane || c.user || c.tester || !window.MosaicProjects) return; const project = c.projects.find(p => p.id === c.id); const projectContent = c.container.querySelector('[data-project-content]'); if (redesigned && (c.kind === 'project' || c.kind === 'thread' || c.kind === 'room')) { @@ -526,6 +547,7 @@ // visible view is unchanged: a request's phase moves without a new message. A quiet reread // keeps the present content until a changed result arrives. function refreshRequestData(c, quiet) { + if (pane) return; window.MosaicRequests?.mount?.(c.container, { archiveUnknown: !!c.archiveUnknown, projectArchived: !!c.projectArchived }); const t = c.kind === 'thread' ? c.thread : null; const repo = c.kind === 'project' ? c.projects.find(p => p.id === c.id)?.repo : libraryOf(c, t); @@ -562,7 +584,7 @@ }); for (let i = 0; i < desired.length; i++) if (liveLog.children[i] !== desired[i]) liveLog.insertBefore(desired[i], liveLog.children[i] || null); while (liveLog.children.length > desired.length) liveLog.lastElementChild.remove(); - window.MosaicRequests?.mount?.(c.container, { archiveUnknown: !!c.archiveUnknown, projectArchived: !!c.projectArchived }); + if (!pane) window.MosaicRequests?.mount?.(c.container, { archiveUnknown: !!c.archiveUnknown, projectArchived: !!c.projectArchived }); const s = state(c.key ?? c.id); if (s.tailRequested || s.follow && ((keep?.tail ?? true) || keep?.settlingPin)) pinTranscript(liveLog); else if (keep?.top !== undefined) { if (pageFlow(liveLog)) window.scrollTo(0, keep.top); else liveLog.scrollTop = keep.top; } @@ -604,8 +626,8 @@ function paint(navigated = false, read = false) { if (!current || out()) return; const keep = capture(), c = current; - const redesigned = (!legacyRoute() || c.path?.startsWith('/projects')) && !!window.MosaicProjects && !c.user && !c.tester; - let html = '

    Projects

    '; + const redesigned = !pane && (!legacyRoute() || c.path?.startsWith('/projects')) && !!window.MosaicProjects && !c.user && !c.tester; + let html = pane ? '
    ' : '

    Projects

    '; if (c.orgArchived) html += `

    ${c.orgArchived === 'room-not-open' ? 'This room is not open.' : 'This organization is archived.'} Sending is unavailable; your draft is kept.

    `; if (c.error && !(c.kind === 'room' && c.error === ROOM_LINES['room-not-open'] && redesigned)) html += ``; if (c.archiveUnknown && !c.loading) html += '

    Archive status unavailable. Some changes are unavailable; sending may be refused; refresh to check.

    '; @@ -635,8 +657,10 @@ } 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) 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.

    '; + 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.

    '; + 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}`); // Drafts already live in the focused textarea; their changing text is not a // reason for a background read to replace the control. const fingerprint = visualKey(html); @@ -690,13 +714,13 @@ // which names the project as the organization file does: the chat project's repo key. if (remote && roomOwner() && window.MosaicOrg && typeof window.MosaicProjectRepo?.mount === 'function') window.MosaicProjectRepo.mount({ container: remote, project: project?.repo ?? null, archived: !!c.projectArchived }); else if (remote && roomOwner() && typeof window.MosaicProjectRemote?.mount === 'function') { remote.replaceChildren(); window.MosaicProjectRemote.mount({ container: remote, projectId: c.id, repositoryKey: project?.repo ?? null, projectArchived: !!c.projectArchived, preserve: true }); } - if (!window.MosaicOrg && (!redesigned || c.kind !== 'project' || c.settings || c.projectArchived)) window.MosaicProjectArchive?.mount(c.container, c); + if (!pane && !window.MosaicOrg && (!redesigned || c.kind !== 'project' || c.settings || c.projectArchived)) window.MosaicProjectArchive?.mount(c.container, c); if (redesigned && c.kind === 'project' && c.projectArchived && !c.settings) { const controls = c.container.querySelector('.project-archive-control'); if (controls) c.container.querySelector('.project-tabs')?.insertAdjacentElement('afterend', controls); } refreshProjectData(c, redesigned, !navigated ? keep : null); - document.title = `${c.container.querySelector('h1')?.textContent ?? 'Chat'} · Mosaic Console`; + if (!pane) document.title = `${c.container.querySelector('h1')?.textContent ?? 'Chat'} · Mosaic Console`; if (!navigated && keep?.toggles) [...c.container.querySelectorAll('.project-new-conversation,.project-create')].forEach((e,i)=>{e.open=!!keep.toggles[i];}); const details = c.container.querySelector('.chat-details'); if (details && keep?.detailsOpen && details.tagName === 'DETAILS') details.open = true; @@ -736,7 +760,7 @@ f.focus({ preventScroll: true }); if (keep.field.start != null && typeof f.setSelectionRange === 'function') f.setSelectionRange(keep.field.start, keep.field.end); } - } else if (navigated) c.container.querySelector('h1')?.focus({ preventScroll: true }); + } else if (navigated && !pane) c.container.querySelector('h1')?.focus({ preventScroll: true }); updateRoomCompletion(); } function readError(err) { @@ -744,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 }) { + async function render({ path, container, canSend, user = false, tester = false, navigated = false, next = false, stopped = false }) { if (out()) return; const originalPath = path; const sectionRoute = /^\/projects\/([0-9a-f-]+)\/(settings|conversations|tasks|requirements|files)$/i.exec(path); @@ -765,7 +789,7 @@ paint(); return; } const old = current; - 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 = { 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.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; @@ -773,8 +797,8 @@ // server paths lack archive guards, and the request panel's writes. const withdraw = () => { lastPaint = null; - for (const el of container.querySelectorAll('#chat-session-form input, #chat-session-form button, #chat-share-form input, #chat-share-form button, [data-unshare], #chat-cancel, [data-revert]')) el.disabled = true; - window.MosaicRequests?.mount?.(container, { archiveUnknown: !!current.archiveUnknown, projectArchived: !!current.projectArchived }); + for (const el of container.querySelectorAll(['chat-session-form input', 'chat-session-form button', 'chat-share-form input', 'chat-share-form button', 'chat-cancel'].map(x => '#' + pid(x)).concat('[data-unshare]', '[data-revert]').join(', '))) el.disabled = true; + if (!pane) window.MosaicRequests?.mount?.(container, { archiveUnknown: !!current.archiveUnknown, projectArchived: !!current.projectArchived }); if (!current.archiveUnknown) return; if (container.querySelector('[data-archive-notice]')) return; const notice = document.createElement('p'); @@ -815,7 +839,8 @@ current.thread = current.threads.find(t => t.id === id); current.shared = current.thread?.shared === true; const projectId = kind === 'project' ? id : current.thread?.project; - if (projectId && !current.projects.some(p => p.id === projectId) && !user && !tester) { + // A pane leaves an unlisted project's archive state unknown: the archive lookup reads the page's organization. + if (projectId && !current.projects.some(p => p.id === projectId) && !user && !tester && !pane) { try { if (typeof window.MosaicProjectArchive?.resolve !== 'function') throw Error('archive-unavailable'); const resolved = await window.MosaicProjectArchive.resolve(projectId, signal); @@ -830,7 +855,7 @@ current.archiveUnknown = true; } } - else if (projectId) current.archiveUnknown = false; + else if (projectId && (!pane || current.projects.some(p => p.id === projectId))) current.archiveUnknown = false; current.projectArchived = current.projects.some(p => p.id === projectId && p.archived); // A same-thread refresh kept the controls of the last resolved (live) state. If this // lookup now finds the project archived or unknown, withdraw them before the slower @@ -958,7 +983,7 @@ for (const row of state(current.key).rows) if (row.from_role === 'human' && typeof row.request === 'string' && REQUEST_ID.test(row.request)) ids.add(row.request); knownRequests.set(projectId, { repo: project.repo, ids }); } - if (project) document.dispatchEvent(new CustomEvent('mosaic-project-context', { detail: { projectId, repositoryKey: project.repo ?? null, thread: kind === 'thread' ? id : null, session: kind === 'thread' ? current.session : null } })); + if (project && !pane) document.dispatchEvent(new CustomEvent('mosaic-project-context', { detail: { projectId, repositoryKey: project.repo ?? null, thread: kind === 'thread' ? id : null, session: kind === 'thread' ? current.session : null } })); } paint(navigated, true); const t = kind === 'thread' ? current.threads.find(t => t.id === id) : null; @@ -988,17 +1013,17 @@ } } function refresh(next = false) { - if (current) return render({ ...current, path: (window.MosaicOrg?.route() ?? location.hash.slice(1)).split('?')[0], next }); + if (current) return render({ ...current, path: pane ? current.path : (window.MosaicOrg?.route() ?? location.hash.slice(1)).split('?')[0], next }); } function leave(path) { if (!path.startsWith('/chat') && !path.startsWith('/projects')) { generation++; controller?.abort(); current = null; } } - document.addEventListener('input', event => { - if (!current || out()) return; - if (event.target.id === 'chat-room-body') { state(current.key).draft = event.target.value; updateRoomCompletion(); return; } - if (event.target.id === 'chat-operation') { const f = form('clear:' + current.key); if (f.pending) return; f.op = event.target.value; f.record = null; const button = document.getElementById('chat-cancel'); if (button) button.disabled = !current.canSend || current.archiveUnknown || current.projectArchived || f.pending || !f.op; } - if (event.target.id === 'chat-share-target') form('share:' + current.id).to = event.target.value; - if (event.target.id === 'chat-body') state(current.key ?? current.id).draft = event.target.value; + on('input', event => { + if (!current || out() || !mine(event.target)) return; + if (idOf(event.target) === 'chat-room-body') { state(current.key).draft = event.target.value; updateRoomCompletion(); return; } + if (idOf(event.target) === 'chat-operation') { const f = form('clear:' + current.key); if (f.pending) return; f.op = event.target.value; f.record = null; const button = byId('chat-cancel'); if (button) button.disabled = !current.canSend || current.archiveUnknown || current.projectArchived || f.pending || !f.op; } + if (idOf(event.target) === 'chat-share-target') form('share:' + current.id).to = event.target.value; + if (idOf(event.target) === 'chat-body') state(current.key ?? current.id).draft = event.target.value; const f = event.target.closest('.chat-create'); if (f && event.target.dataset.chatField) { const draft = form(f.dataset.key), field = event.target.dataset.chatField; @@ -1010,7 +1035,7 @@ // User input cancels a pending page pin, including scrollbar dragging. // Tab counts from any target: it moves focus and can scroll the document. const readerInput = event => { - if (!['thread', 'room'].includes(current?.kind)) return; + if (!['thread', 'room'].includes(current?.kind) || !mine(event.target)) return; if (event.type === 'keydown' && event.key !== 'Tab' && (!['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', ' '].includes(event.key) || event.target.closest?.('input, textarea, select, button, a, [contenteditable=true]'))) return; if (event.type === 'pointerdown' && event.target !== document.documentElement && event.target !== document.body && !event.target.matches?.('.chat-log')) return; const log = current.container.querySelector('.chat-log'); @@ -1024,8 +1049,8 @@ } else if (event.key === 'Tab' || event.target === log || log.contains(event.target)) { pinnedScrolls.delete(log); settlingPins.delete(log); } }; - for (const type of ['wheel', 'touchstart', 'pointerdown', 'keydown']) document.addEventListener(type, readerInput, true); - document.addEventListener('scroll', event => { + for (const type of ['wheel', 'touchstart', 'pointerdown', 'keydown']) on(type, readerInput, true); + on('scroll', event => { if (!['thread', 'room'].includes(current?.kind)) return; const log = current.container.querySelector('.chat-log'); if (!log) return; @@ -1110,25 +1135,25 @@ if (!out() && current && (isThread ? (current.key ?? current.id) === key : (action === 'createSession' ? current.id === targetThread : key === 'projects' ? current.kind === 'list' : current.id === key))) paint(); } } - document.addEventListener('submit', event => { - if (!current || !event.target.id.startsWith('chat-')) return; + on('submit', event => { + if (!current || !mine(event.target) || !idOf(event.target).startsWith('chat-')) return; event.preventDefault(); - if (event.target.id === 'chat-room-send-form') { + if (idOf(event.target) === 'chat-room-send-form') { void sendRoom(); - } else if (event.target.id === 'chat-share-form') { + } else if (idOf(event.target) === 'chat-share-form') { void changeShare('share', form('share:' + current.id).to ?? ''); - } else if (event.target.id === 'chat-send-form') { + } else if (idOf(event.target) === 'chat-send-form') { const id = current.id, session = current.session, key = current.key; void write('send', key, { body: state(key).draft, ...(session ? { session } : {}) }, `threads/${id}/messages`); - } else if (event.target.id === 'chat-session-form') { + } else if (idOf(event.target) === 'chat-session-form') { const key = 'session:' + current.id, f = form(key); // Keep an ambiguous create's key and payload; editing requires a deliberate new attempt. f.op ??= opId(); f.payload ??= { name: f.name, op: f.op }; void write('createSession', key, f.payload, `threads/${current.id}/sessions`); - } else if (event.target.id === 'chat-operation-form') { + } else if (idOf(event.target) === 'chat-operation-form') { const clear = form('clear:' + current.key); - clear.op = document.getElementById('chat-operation').value; + clear.op = byId('chat-operation').value; void checkClear(current.id, current.key, clear); } else if (event.target.classList.contains('chat-create')) { const key = event.target.dataset.key, f = form(key), project = key === 'projects'; @@ -1137,9 +1162,9 @@ void write(project ? 'createProject' : 'createThread', key, value, project ? 'projects' : 'threads'); } }); - document.addEventListener('click', event => { - if (!current || out()) return; - if (event.target.id === 'chat-room-open' || event.target.closest?.('[data-room-open-inline]')) void openRoom(); + on('click', event => { + if (!current || out() || !mine(event.target)) return; + if (idOf(event.target) === 'chat-room-open' || event.target.closest?.('[data-room-open-inline]')) void openRoom(); const projectRoomOpen=event.target.closest?.('[data-project-room-open]'); if(projectRoomOpen && UUID.test(projectRoomOpen.dataset.projectRoomOpen)) void openRoom(projectRoomOpen.dataset.projectRoomOpen); const chip = event.target.closest?.('[data-room-chip]'); if (chip) { const name = chip.dataset.roomChip; document.querySelector(`[data-room-member="${CSS.escape(name)}"]`)?.click(); document.querySelector(`[data-room-chip="${CSS.escape(name)}"]`)?.focus(); } @@ -1154,16 +1179,17 @@ const option = event.target.closest?.('[data-room-complete]'); if (option) completeRoom(option.dataset.roomComplete); if (event.target.dataset.unshare) void changeShare('unshare', event.target.dataset.unshare); - if (event.target.id === 'chat-resume') void write('open', current.key, current.session ? { session: current.session } : {}, `threads/${current.id}/open`); - if (event.target.id === 'chat-cancel') void cancelClear(); - if (event.target.id === 'chat-clear') void clearContext(); - if (event.target.id === 'chat-next') void refresh(true); + if (idOf(event.target) === 'chat-resume') void write('open', current.key, current.session ? { session: current.session } : {}, `threads/${current.id}/open`); + if (idOf(event.target) === 'chat-cancel') void cancelClear(); + if (idOf(event.target) === 'chat-clear') void clearContext(); + if (idOf(event.target) === 'chat-next') void refresh(true); if (event.target.closest?.('[data-chat-retry]')) void refresh(); const revertButton = event.target.closest?.('[data-revert]'); if (revertButton) void revert(revertButton.dataset.revert); - if (event.target.id === 'chat-new') { state(current.key ?? current.id).tailRequested = true; state(current.key ?? current.id).follow = true; paint(); } + if (idOf(event.target) === 'chat-new') { state(current.key ?? current.id).tailRequested = true; state(current.key ?? current.id).follow = true; paint(); } }); - document.addEventListener('change', event => { + on('change', event => { + if (!mine(event.target)) return; if (event.target.matches?.('[data-room-member]') && current?.kind === 'room') { const field = document.getElementById('chat-room-body'), s = state(current.key); const parsed = roomAddress(field.value); @@ -1175,7 +1201,7 @@ s.draft = chosen.map(name => '@' + name).join(' ') + (parsed.body ? ' ' + parsed.body : ' '); s.receipt = ''; paint(); return; } - if (event.target.id === 'chat-session' && current && UUID.test(event.target.value)) { + if (idOf(event.target) === 'chat-session' && current && UUID.test(event.target.value)) { location.hash = sessionPath(current.id, event.target.value); } }); @@ -1246,7 +1272,23 @@ if (!out() && current?.key === key) { await refresh(); paint(); } } } - document.addEventListener('mosaic-signed-out', () => { signedOut = true; forget(); }); + 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)); - window.MosaicChat = Object.freeze({ render, leave, forget, projectPath, hasDraft }); + // 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 }; + } + const main = chatInstance(); + const ORG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; + let panes = 0; + // An Assistant pane: one thread of one organization, read and sent through that organization's own routes. + 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 }); + } + window.MosaicChat = Object.freeze({ render: main.render, leave: main.leave, forget: main.forget, projectPath: main.projectPath, hasDraft: main.hasDraft, pane }); })(); diff --git a/packages/webui/src/public/display.js b/packages/webui/src/public/display.js index 3ea70e74c..44fd10712 100644 --- a/packages/webui/src/public/display.js +++ b/packages/webui/src/public/display.js @@ -25,7 +25,8 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date( // organization's items); `#/orgs//inbox` is that list, filtered. SP5: `#/agents` is every // organization's agents; `#/orgs//agents` is that table for one organization. SP4: `#/tasks` is // one task board for every organization; `#/orgs//tasks` is that board, scoped to one. - const system = hash => { const bare = tail(hash).split('?')[0]; return bare === '/dashboard' || (!orgOf(hash) && (bare === '/inbox' || bare === '/agents' || bare === '/tasks')); }; + // SP6: `#/assistant` is the Assistant page, threads of any organization side by side. + const system = hash => { const bare = tail(hash).split('?')[0]; return bare === '/dashboard' || (!orgOf(hash) && (bare === '/inbox' || bare === '/agents' || bare === '/tasks' || bare === '/assistant')); }; const normalize = (hash, seg = current) => { const path = tail(hash), bare = path.split('?')[0], named = /^#\/orgs\/([^/?]+)$/.exec(hash.split('?')[0])?.[1]; if (named) return `#/orgs/${named}/organization${hash.slice(hash.split('?')[0].length)}`; diff --git a/packages/webui/src/public/index.html b/packages/webui/src/public/index.html index 6d6ce3ee7..7092bb291 100644 --- a/packages/webui/src/public/index.html +++ b/packages/webui/src/public/index.html @@ -6,8 +6,8 @@ - - + + diff --git a/packages/webui/src/public/panes.css b/packages/webui/src/public/panes.css deleted file mode 100644 index f137feca6..000000000 --- a/packages/webui/src/public/panes.css +++ /dev/null @@ -1,93 +0,0 @@ -/* Side by side (U5, sample 07 "EXT 8"): a bar, then two panes with a divider between them. Each pane - has its own header with the organization's mark and edge, and its own scroll. One pane and a - shown-pane switch on a phone. Loaded after shell.css; every rule is scoped to the side-by-side page. */ -html[data-organizations] .content:has(> #bus-view > .sbs){padding:0} -html[data-organizations] .sbs{margin:0;display:flex;flex-direction:column;min-height:0;height:calc(100vh - var(--bar-h,53px) - var(--foot-h,33px))} -html[data-organizations] .sbs .panebar{display:flex;align-items:center;gap:10px;padding:8px 14px;border-bottom:1px solid var(--line);background:var(--canvas);flex-wrap:wrap} -html[data-organizations] .sbs .panebar h1{font-size:16px;font-weight:700;margin:0;line-height:1.4} -html[data-organizations] .sbs .panebar h1:focus{outline:none} -html[data-organizations] .sbs .panebar .note{font-size:13px;color:var(--muted)} -html[data-organizations] .sbs .panebar [data-pane-add]{margin-left:auto} -html[data-organizations] .sbs .ws-seg.pane-tabs{display:none;margin-left:0} -html[data-organizations] .sbs .pane-tabs>button{gap:5px;max-width:44vw} -html[data-organizations] .sbs .pane-tabs>button>span:last-child{overflow:hidden;text-overflow:ellipsis} - -html[data-organizations] .panes{--sbs-a:1fr;--sbs-b:1fr;display:grid;grid-template-columns:minmax(0,1fr);flex:1;min-height:0} -html[data-organizations] .panes.two{grid-template-columns:minmax(0,var(--sbs-a)) 1px minmax(0,var(--sbs-b))} -html[data-organizations] .pane{display:flex;flex-direction:column;min-width:0;min-height:0} -html[data-organizations] .pane[hidden]{display:none} -html[data-organizations] .pane-h{display:flex;align-items:center;gap:8px;padding:8px 14px;border-bottom:1px solid var(--line);background:var(--surface);box-shadow:inset 0 3px 0 var(--org,var(--line));min-width:0} -html[data-organizations] .pane-h .ttl{font-weight:700;color:var(--strong,var(--text));overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:0 1 auto} -html[data-organizations] .pane-h .ttl small{font-weight:500;color:var(--muted);margin-left:6px;font-size:inherit} -html[data-organizations] .pane-h [data-pane-state]:empty{display:none} -html[data-organizations] .pane-pick{flex:0 1 180px;min-width:0;max-width:220px;margin-left:auto;font:500 12px/1.4 var(--font);color:var(--text);background:var(--canvas);border:1px solid var(--line);border-radius:var(--r);padding:3px 6px;min-height:0} -html[data-organizations] .pane-h .tools{display:flex;gap:2px;flex:none} -html[data-organizations] .pane-tool{display:inline-grid;place-items:center;width:26px;height:26px;border-radius:var(--r);color:var(--muted);border:0;background:none;padding:0;min-height:0;cursor:pointer} -html[data-organizations] .pane-tool:hover{background:var(--raised);color:var(--text)} -html[data-organizations] .pane-tool[hidden]{display:none} -html[data-organizations] .pane-tool svg.i{width:15px;height:15px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round} -html[data-organizations] .pane-line{margin:0;padding:6px 14px;border-bottom:1px solid var(--line);font-size:13px;color:var(--text);background:color-mix(in srgb,var(--warn,#b7791f) 12%,var(--surface))} -html[data-organizations] .pane-line[hidden]{display:none} -html[data-organizations] .pane-body{overflow:auto;padding:14px;min-height:0;flex:1} -html[data-organizations] .pane-body:focus{outline:none} -html[data-organizations] .pane-body:focus-visible{outline:2px solid var(--focus,var(--action));outline-offset:-2px} -/* A page inside a pane is a section of the side-by-side page: smaller headings, no page chrome. */ -html[data-organizations] .pane-body .ws h1{font-size:20px} -html[data-organizations] .pane-body .chat-area.pane-project{display:block;height:auto;min-height:0;overflow:visible;padding:0} -/* A project in a pane is compact (sample 07): the pane header already names the project and its - organization, so the page heading stays only for assistive technology and focus, the full-page - actions give way (North star and PRD are on the Requirements tab, Settings in the full view), and - the tabs, measures and current work come first. Project pages size some parts from the window - (projects.css); in a pane they size from the pane. */ -html[data-organizations] .pane-body{container-type:inline-size} -html[data-organizations] .pane-body .project-header{display:block;width:auto;max-width:100%;margin:0 0 6px;padding:0} -html[data-organizations] .pane-body .project-header .project-heading,html[data-organizations] .pane-body .project-header .project-heading>div{display:block} -html[data-organizations] .pane-body .project-header-icon,html[data-organizations] .pane-body .project-actions{display:none} -html[data-organizations] .pane-body .project-header h1{position:absolute;width:1px;height:1px;margin:-1px;padding:0;border:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap} -html[data-organizations] .pane-body:has(.project-header h1:focus-visible){outline:2px solid var(--focus,var(--action));outline-offset:-2px} -html[data-organizations] .pane-body .project-header .project-heading p{margin:0;font-size:13px;color:var(--muted)} -html[data-organizations] .pane-body .project-tabs a{min-height:40px;padding:0 12px;font-size:14px} -html[data-organizations] .pane-body .project-metrics{grid-template-columns:repeat(2,minmax(0,1fr));margin-top:10px;padding:6px 0} -html[data-organizations] .pane-body .project-metrics a{padding:4px 12px;border-right:0;border-bottom:0;line-height:1.3} -html[data-organizations] .pane-body .project-metrics a span,html[data-organizations] .pane-body .project-metrics small{font-size:12px;overflow-wrap:anywhere} -html[data-organizations] .pane-body .project-metrics strong{font-size:20px} -html[data-organizations] .pane-body .project-metrics p{margin:6px 12px 0;padding-top:6px} -html[data-organizations] .pane-body .project-columns{grid-template-columns:minmax(0,1fr);gap:14px;margin-top:12px} -html[data-organizations] .pane-body .project-columns h2{font-size:16px;margin:12px 0 8px} -html[data-organizations] .pane-body .project-columns h3{font-size:14px} -html[data-organizations] .pane-body .project-columns .project-section-title h2,html[data-organizations] .pane-body .project-columns aside>h2:first-child{margin-top:0} -@container (min-width:560px){ - html[data-organizations] .pane-body .project-metrics{grid-template-columns:repeat(4,minmax(0,1fr))} - html[data-organizations] .pane-body .project-metrics a:not(:nth-child(4)){border-right:1px solid var(--line)} -} -/* E2E6 P2-9: a dashboard in a pane sizes from the pane, not the window. Half a 1440 window left the - Settings column too narrow for its values (clipped to 1-2 characters): the split stacks below 900px - of pane, and a narrow Settings list puts each value on its own line, as at phone width. */ -@container (max-width:899px){ - html[data-organizations] .pane-body .ws-split{grid-template-columns:minmax(0,1fr)} -} -@container (max-width:559px){ - html[data-organizations] .pane-body .ws-cfg li{grid-template-columns:18px minmax(0,1fr) auto auto} - html[data-organizations] .pane-body .ws-cfg .v{grid-column:2/-1;grid-row:2} -} -/* The side-by-side page has its own bar; the page breadcrumb would name only one pane. */ -body:has(#bus-view > .sbs) #breadcrumbs{display:none} -html[data-organizations] .content:has(> #bus-view > .sbs) > .org-cue{margin:8px 14px} - -html[data-organizations] .pane-div{position:relative;background:var(--line);cursor:col-resize;touch-action:none;outline:none} -html[data-organizations] .pane-div::before{content:"";position:absolute;inset:0 -4px;z-index:1} -html[data-organizations] .pane-div:hover,html[data-organizations] .pane-div.dragging{background:var(--action)} -html[data-organizations] .pane-div:focus-visible{background:var(--action);box-shadow:0 0 0 2px var(--action)} -html[data-organizations] .pane-div[hidden]{display:none} - -@media (max-width:760px){ - html[data-organizations] .content:has(> #bus-view > .sbs){padding:0} - html[data-organizations] .sbs{height:auto} - html[data-organizations] .sbs .ws-seg.pane-tabs{display:inline-flex} - html[data-organizations] .sbs .hide-m{display:none} - html[data-organizations] .panes,html[data-organizations] .panes.two{grid-template-columns:minmax(0,1fr)} - html[data-organizations] .pane.alt,html[data-organizations] .pane-div{display:none} - html[data-organizations] .pane-body{overflow:visible} - html[data-organizations] .pane-h{flex-wrap:wrap} - html[data-organizations] .pane-pick{flex:1 1 100%;max-width:none;order:5;margin-left:0} -} diff --git a/packages/webui/src/public/panes.js b/packages/webui/src/public/panes.js deleted file mode 100644 index e0cb94a24..000000000 --- a/packages/webui/src/public/panes.js +++ /dev/null @@ -1,484 +0,0 @@ -// Side by side (U5, sample 07): two panes, each showing one page of any organization. A pane holds -// the organization dashboard, the Agents levels or a project page (overview, conversations, tasks, -// requirements, files), drawn by the same modules as the full view (workspace.js, projects.js) into -// the pane instead of #bus-view. Every read names the pane's organization, so the panes never share -// state. Pages that keep one global form, dialog or editor (a conversation, the room, settings, the -// queue, docs, the inbox) are not offered in a pane; their links open the full view. -// -// The panes' routes, the divider and the phone's shown pane live in the URL -// (#/orgs//side-by-side?a=/&b=/&w=&show=a|b), updated with -// replaceState so nothing reloads, and the last layout is remembered in this browser. -(() => { - 'use strict'; - if (!document.documentElement.hasAttribute('data-organizations')) return; - const data = window.MosaicWorkspaceData, ws = () => window.MosaicWorkspace; - const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); - const enc = encodeURIComponent; - const STORE = 'mosaic-side-by-side-v1', POLL = 10000, LIST_TTL = 3000, PROJECTS_TTL = 30000; - const MIN = 25, MAX = 75, STEP = 5; - const ORG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; - const PROJECT = /^\/projects\/([A-Za-z0-9][A-Za-z0-9._-]{0,159})(?:\/(conversations|tasks|requirements|files))?$/; - const SECTIONS = { overview: 'Overview', conversations: 'Conversations', tasks: 'Tasks', requirements: 'Requirements', files: 'Files' }; - const FOCUSABLE = 'a[href],button:not([disabled]),select,textarea,input,summary,[tabindex]:not([tabindex="-1"])'; - const NAMES = { a: 'left', b: 'right' }; - const load = () => { try { return localStorage.getItem(STORE); } catch { return null; } }; - const save = value => { try { localStorage.setItem(STORE, value); } catch {} }; - const paused = () => document.getElementById('pause')?.getAttribute('aria-pressed') === 'true'; - const svg = d => ``; - const FULL = svg(''); - const CLOSE = svg(''); - const PLUS = svg(''); - - // A pane's page: the routes a pane can draw. Anything else is not pane-safe. - function page(route) { - if (route === '/organization') return { kind: 'dashboard' }; - if (route === '/agents') return { kind: 'agents' }; - const m = PROJECT.exec(route); - if (m && m[1] !== 'threads' && m[1] !== 'rooms') return { kind: 'project', id: m[1], section: m[2] || 'overview' }; - return null; - } - // "/" from the URL; null when either part is not one a pane draws. - function parsePane(value) { - if (typeof value !== 'string') return null; - const cut = value.indexOf('/'); - if (cut < 1) return null; - const org = value.slice(0, cut), route = value.slice(cut); - return ORG.test(org) && page(route) ? { org, route } : null; - } - const paneValue = p => `${p.org}${p.route}`; - const clampW = n => Math.min(MAX, Math.max(MIN, Math.round(n))); - - // ---------- shared reads (one organization list and project lists for both panes) ---------- - let listAt = 0, listPromise = null, projectReads = new Map(); - function orgList(fresh = false) { - if (!listPromise || fresh || Date.now() - listAt > LIST_TTL) { - listAt = Date.now(); - listPromise = Promise.all([data.organizations(), ws()?.themes?.()]).then(([rows]) => (Array.isArray(rows) ? rows : null)).catch(() => null); - } - return listPromise; - } - function projectsOf(org, fresh = false) { - const hit = projectReads.get(org); - if (hit && !fresh && Date.now() - hit.at < PROJECTS_TTL) return hit.promise; - const promise = data.projects(org).then(b => (Array.isArray(b?.rows) ? b.rows.filter(p => p && typeof p.id === 'string') : null)).catch(() => null); - projectReads.set(org, { at: Date.now(), promise }); - return promise; - } - - // ---------- state ---------- - let mounted = null; // { root, panes: [pane], w, show, gen } - let mountGen = 0; - let listCache = null; // the last organization list read, for the shown-pane tabs - - function readQuery(query) { - const q = new URLSearchParams(query || ''); - return { a: parsePane(q.get('a')), b: parsePane(q.get('b')), w: Number(q.get('w')), show: q.get('show'), named: q.has('a') || q.has('b') }; - } - function queryOf(state) { - const q = new URLSearchParams(); - state.panes.forEach((p, i) => q.set(i ? 'b' : 'a', paneValue(p))); - q.set('w', String(state.w)); - q.set('show', state.show); - return q.toString().replace(/%2F/g, '/'); - } - function writeUrl() { - if (!mounted) return; - const qs = queryOf(mounted); - save(qs); - const next = `#/orgs/${enc(window.MosaicOrg.id)}/side-by-side?${qs}`; - if (location.hash !== next) history.replaceState(null, '', next); - } - // The first pane is this page's organization; the second is the next organization on the list, - // or this one's Agents when there is no other. - async function defaults() { - const here = window.MosaicOrg.id, list = await orgList(); - const other = (list || []).find(o => o && !o.invalid && o.id !== here && ORG.test(o.id)); - return [{ org: here, route: '/organization' }, other ? { org: other.id, route: '/organization' } : { org: here, route: '/agents' }]; - } - - // ---------- markup ---------- - function paneShell(key) { - const name = NAMES[key]; - return `
    -
    - - - - Open in full view${FULL} -
    -

    -
    -
    `; - } - function frame() { - return `
    -

    Side by side

    - -
    -
    ${paneShell('a')}${paneShell('b')}
    -
    `; - } - - // Orgs in the picker: every organization on the list. A pane draws a valid one's dashboard, Agents or - // any project the organization's project read lists; an invalid one is listed and cannot be chosen. - async function pickerHtml(p, list) { - const groups = await Promise.all((list || []).filter(o => o && ORG.test(o.id)).map(async org => { - const name = ws().label(org); - if (org.invalid) return ``; - const rows = await projectsOf(org.id); - const opt = (route, text) => ``; - const projects = rows === null ? '' : rows.filter(r => !r.archived && PROJECT.test(`/projects/${r.id}`)).map(r => opt(`/projects/${r.id}`, `Project: ${r.name || r.id}`)).join(''); - return `${opt('/organization', 'Dashboard')}${opt('/agents', 'Agents')}${projects}`; - })); - let html = groups.join(''); - const value = paneValue(p); - // A project section, or a page the list no longer names, is still the pane's page: keep it selectable. - if (!html.includes(`value="${esc(value)}"`)) html = `${html}`; - return html; - } - - // ---------- drawing one pane ---------- - const els = p => ({ - head: p.el.querySelector('[data-pane-head]'), av: p.el.querySelector('[data-pane-av]'), ttl: p.el.querySelector('[data-pane-ttl]'), - state: p.el.querySelector('[data-pane-state]'), pick: p.el.querySelector('[data-pane-pick]'), full: p.el.querySelector('[data-pane-full]'), - fullLabel: p.el.querySelector('[data-pane-full-label]'), close: p.el.querySelector('[data-pane-close]'), line: p.el.querySelector('[data-pane-line]'), body: p.el.querySelector('[data-pane-body]'), - }); - const setHtml = (el, html) => { if (el && el.innerHTML !== html) el.innerHTML = html; }; - const setText = (el, text) => { if (el && el.textContent !== text) el.textContent = text; }; - - // Links a page draws are relative to the page's organization. In a pane they name the pane's - // organization; reads the page links to (/api/...) name it too. - function rewrite(p, root) { - const prefix = `#/orgs/${enc(p.org)}`; - for (const a of root.querySelectorAll('a[href^="#/"]')) if (!a.getAttribute('href').startsWith('#/orgs/')) a.setAttribute('href', prefix + a.getAttribute('href').slice(1)); - for (const a of root.querySelectorAll('a[href^="/api/"]')) if (!a.getAttribute('href').startsWith('/api/orgs/')) a.setAttribute('href', `/api/orgs/${enc(p.org)}/${a.getAttribute('href').slice(5)}`); - // The pane's title is the page's heading; the side-by-side page keeps the one level-1 heading. - for (const h of root.querySelectorAll('h1:not([aria-level])')) h.setAttribute('aria-level', '2'); - } - // Focus and scroll survive a repaint of this pane only. Focus in the other pane is never taken. - function capture(p, root) { - const a = document.activeElement, inside = !!a && a !== root && root.contains(a); - return { - top: root.scrollTop, - focus: inside ? { key: a.dataset?.focusKey || null, href: a.getAttribute('href'), index: [...root.querySelectorAll(FOCUSABLE)].indexOf(a), h1: a.tagName === 'H1' } : null, - }; - } - function restore(p, root, kept) { - root.scrollTop = kept.top; - const f = kept.focus, a = document.activeElement; - if (!f || (a && a !== document.body && !root.contains(a))) return; - const target = (f.key && root.querySelector(`[data-focus-key="${CSS.escape(f.key)}"]`)) - || (f.href && [...root.querySelectorAll('a[href]')].find(x => x.getAttribute('href') === f.href)) - || (f.h1 && root.querySelector('h1')) - || root.querySelectorAll(FOCUSABLE)[f.index]; - (target || root).focus({ preventScroll: true }); - } - function paintBody(p, html) { - const { body } = els(p); - if (p.html === html && body.childElementCount) return; - const kept = capture(p, body), live = window.MosaicWorkspace?.holdLive?.(body); - body.innerHTML = html; p.html = html; p.project = null; - rewrite(p, body); - // E2E6 P1-1: an editor in use keeps its node, draft and focus; its ids were rewritten when first painted. - if (live && window.MosaicWorkspace.graftLive(body, live)) kept.focus = null; - restore(p, body, kept); - } - - // A focus move that finishes after an asynchronous read happens only if the viewer has not - // moved on: focus is still where it was when the action started, nowhere, or inside this pane. - function mayFocus(p, before) { - const a = document.activeElement; - return !a || a === document.body || a === before || p.el.contains(a); - } - async function drawPane(p, { fresh = false, focusHead = false } = {}) { - if (!mounted || !p.el.isConnected) return; - const before = document.activeElement; - const turn = ++p.turn, gen = mountGen, live = () => mounted && gen === mountGen && turn === p.turn && p.el.isConnected; - clearTimeout(p.timer); - const e = els(p), what = page(p.route); - const list = await orgList(fresh); - if (!live()) return; - if (list) listCache = list; - const org = list?.find(o => o?.id === p.org) || null; - const name = org ? ws().label(org) : p.org; - // Header: the organization's mark, the page's title, its state, the picker and the tools. - if (org) e.head.setAttribute('style', ws().colours(org)); else e.head.removeAttribute('style'); - setHtml(e.av, org ? ws().av(org, 'md') : ''); - const [scls, sglyph, sword] = org ? ws().state(org) : ['s-unknown', '?', 'not on your list']; - setHtml(e.state, org?.busHealthy && !org.invalid ? '' : ws().dot(scls, sglyph, sword, true)); - const fullHref = `#/orgs/${enc(p.org)}${p.route}`; - if (e.full.getAttribute('href') !== fullHref) e.full.setAttribute('href', fullHref); - - let line = '', html = null; - if (!list) line = 'The organization list could not be read. This pane shows what its own reads return.'; - else if (!org) { line = `${p.org} is not on your organization list.`; html = '

    Nothing is read for an organization that is not on your list.

    '; } - else if (org.invalid) { line = `${name}: organization invalid${org.reason ? ` (${org.reason})` : ''}.`; html = '

    Nothing is read from an invalid organization. Fix its file under Settings, Organizations.

    '; } - else if (!org.busHealthy) line = `${name}: bus unavailable. Reads from this organization may fail; the other pane is not affected.`; - setText(e.line, line); e.line.hidden = !line; - - let title = what?.kind === 'agents' ? 'Agents' : 'Dashboard'; - if (html === null && what?.kind === 'dashboard') html = await ws().dashboard(p.org, { pane: true, idPrefix: `pane-${p.key}-` }).catch(() => '

    Dashboard unavailable.

    '); - else if (html === null && what?.kind === 'agents') html = await ws().agentGrid('', p.org).catch(() => '

    Agents unavailable.

    '); - if (!live()) return; - if (html === null && what?.kind === 'project') { - title = await drawProject(p, e, what, fresh, live); - if (title === null || !live()) return; - } else if (html !== null) paintBody(p, html); - p.title = title; - setHtml(e.ttl, `${esc(title)}${esc(name)}`); - p.el.setAttribute('aria-label', `${NAMES[p.key][0].toUpperCase()}${NAMES[p.key].slice(1)} pane: ${title}, ${name}`); - setText(e.fullLabel, `Open ${title}, ${name}, in full view`); - // The picker is rebuilt only while it is closed and unfocused, so a choice in progress stays. - if (document.activeElement !== e.pick) { - const options = await pickerHtml(p, list); - if (!live()) return; - if (document.activeElement !== e.pick) { setHtml(e.pick, options); e.pick.value = paneValue(p); } - } - paintBar(); fit(); - if (focusHead && mayFocus(p, before)) (e.body.querySelector('h1') || e.body).focus({ preventScroll: true }); - p.timer = setTimeout(() => tick(p), POLL); - } - function tick(p) { - if (!mounted || !p.el.isConnected) return; - if (paused()) { p.timer = setTimeout(() => tick(p), POLL); return; } - void drawPane(p); - } - - // A project page: projects.js's header and content, mounted into the pane with the pane's - // organization, read-only (a pane never sends). - // Every write below happens only while this draw is still the pane's current one (live()), so a - // slow read never paints an old page or organization over the one the viewer has since chosen. - async function drawProject(p, e, what, fresh, live) { - const org = p.org; - const [projects, threadsRead] = await Promise.all([projectsOf(org, fresh), data.threads(org).catch(() => null)]); - if (!live() || p.org !== org) return null; - const project = projects?.find(r => r.id === what.id); - const sectionName = SECTIONS[what.section]; - if (!project) { - paintBody(p, projects === null ? '

    Projects unavailable. The organization did not answer the project read.

    ' : '

    This project is not in the organization\'s project list.

    '); - return what.id; - } - const threads = Array.isArray(threadsRead?.rows) ? threadsRead.rows : []; - const head = window.MosaicProjects.header(project, what.section, threads); - const conv = what.section === 'conversations' ? conversations(project, threads, threadsRead) : null; - const key = JSON.stringify([org, project.id, what.section, head, conv]); - const { body } = e; - if (p.project?.key !== key) { - const kept = capture(p, body); - body.innerHTML = `
    ${head}${conv ?? '
    Reading…
    '}
    `; - p.html = null; p.project = { key }; - rewrite(p, body); - restore(p, body, kept); - } - const content = body.querySelector('[data-project-content]'); - if (content) await window.MosaicProjects.mountContent({ container: content, project, threads, section: what.section, canSend: false, org, pane: true, views: viewsOf(p, org), idPrefix: `pane-${p.key}-` }); - return what.section === 'overview' ? project.name || project.id : `${project.name || project.id} · ${sectionName}`; - } - // A pane's request choice and PRD disclosure, per organization, kept on the pane so a repaint - // that replaces the project content keeps them. - function viewsOf(p, org) { - if (!p.views.has(org)) p.views.set(org, { selected: new Map(), expanded: new Set() }); - return p.views.get(org); - } - function conversations(project, threads, read) { - if (!read) return '

    Conversations unavailable.

    '; - const own = threads.filter(t => t && !t.shared && t.project === project.id && typeof t.id === 'string'); - const rows = own.map(t => `${esc(t.name || t.id)}With ${esc(t.instance || 'no agent recorded')}`).join(''); - return `

    Conversations

    ${rows || '

    No conversations yet.

    '}

    A conversation opens in its full view.

    `; - } - - // ---------- the bar, tabs, divider and layout ---------- - function paintBar() { - if (!mounted) return; - const { root, panes, show } = mounted, two = panes.length === 2; - setText(root.querySelector('[data-sbs-note]'), `${panes.length} ${panes.length === 1 ? 'pane' : 'panes'} · each pane reads its own organization's bus and keeps its own state`); - const tabs = root.querySelector('[data-sbs-tabs]'), focusedTab = tabs.contains(document.activeElement) ? document.activeElement.dataset.paneShow : null; - const list = listCache; - const html = panes.map(p => { - const org = list?.find(o => o?.id === p.org); - return ``; - }).join(''); - if (tabs.innerHTML !== html) { tabs.innerHTML = html; if (focusedTab) tabs.querySelector(`[data-pane-show="${focusedTab}"]`)?.focus({ preventScroll: true }); } - const add = root.querySelector('[data-pane-add]'); - add.disabled = two; add.title = two ? 'Side by side holds two panes. Close one to choose another.' : 'Open a second pane'; - for (const p of panes) { const c = p.el.querySelector('[data-pane-close]'); c.hidden = !two; } - } - function layout() { - if (!mounted) return; - const { root, panes, w, show } = mounted, two = panes.length === 2; - const box = root.querySelector('[data-sbs-panes]'), divider = root.querySelector('[data-sbs-divider]'); - box.classList.toggle('two', two); - box.style.setProperty('--sbs-a', `${w}fr`); box.style.setProperty('--sbs-b', `${100 - w}fr`); - divider.hidden = !two; - divider.setAttribute('aria-valuenow', String(w)); - divider.setAttribute('aria-valuetext', `Left pane ${w} percent, right pane ${100 - w} percent`); - root.querySelector('#pane-b').hidden = !two; - for (const p of panes) p.el.classList.toggle('alt', show !== p.key); - fit(); - } - // Wide screens: the panes fill the window below the bar and each scrolls on its own. - const wide = window.matchMedia('(min-width: 761px)'); - function fit() { - if (!mounted) return; - const { root } = mounted; - if (!wide.matches) { root.style.height = ''; return; } - const foot = document.querySelector('body > .foot, .foot')?.getBoundingClientRect().height || 0; - const top = root.getBoundingClientRect().top; - root.style.height = `${Math.max(360, Math.floor(window.innerHeight - Math.max(top, 0) - foot))}px`; - } - function setWidth(w, persist = true) { - if (!mounted) return; - const next = clampW(w); - if (next === mounted.w) return; - mounted.w = next; layout(); if (persist) writeUrl(); - } - - // ---------- events (bound once to the mount root) ---------- - function bind(root) { - const divider = root.querySelector('[data-sbs-divider]'); - divider.addEventListener('keydown', e => { - const w = mounted?.w; if (w === undefined) return; - const next = { ArrowLeft: w - STEP, ArrowDown: w - STEP, ArrowRight: w + STEP, ArrowUp: w + STEP, PageDown: w - 10, PageUp: w + 10, Home: MIN, End: MAX }[e.key]; - if (next === undefined) return; - e.preventDefault(); setWidth(next); - }); - divider.addEventListener('pointerdown', e => { - if (e.button !== 0) return; - e.preventDefault(); divider.focus({ preventScroll: true }); divider.setPointerCapture(e.pointerId); divider.classList.add('dragging'); - const box = root.querySelector('[data-sbs-panes]').getBoundingClientRect(); - const move = ev => setWidth(((ev.clientX - box.left) / box.width) * 100, false); - const up = () => { divider.removeEventListener('pointermove', move); divider.removeEventListener('pointerup', up); divider.removeEventListener('pointercancel', up); divider.classList.remove('dragging'); writeUrl(); }; - divider.addEventListener('pointermove', move); divider.addEventListener('pointerup', up); divider.addEventListener('pointercancel', up); - }); - root.addEventListener('change', e => { - const pick = e.target.closest?.('[data-pane-pick]'); if (!pick || !mounted) return; - const p = mounted.panes.find(x => x.el.contains(pick)), next = parsePane(pick.value); - if (!p || !next) return; - go(p, next, false); - }); - root.addEventListener('click', e => { - if (!mounted) return; - const show = e.target.closest?.('[data-pane-show]'); - if (show) { mounted.show = show.dataset.paneShow === 'b' && mounted.panes.length === 2 ? 'b' : 'a'; layout(); paintBar(); writeUrl(); return; } - if (e.target.closest?.('[data-pane-add]')) { void addPane(); return; } - const close = e.target.closest?.('[data-pane-close]'); - if (close) { closePane(mounted.panes.find(x => x.el.contains(close))); return; } - // A plain click on a link to a page a pane draws, in the pane's own organization, opens it in - // the pane. Anything else (another organization, a page a pane does not draw, a new tab) is - // an ordinary link to the full view. - const a = e.target.closest?.('a[href^="#/orgs/"]'); - if (!a || a.hasAttribute('data-pane-full') || e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; - const p = mounted.panes.find(x => x.el.contains(a)); if (!p) return; - const m = /^#\/orgs\/([^/?#]+)(\/[^?#]*)$/.exec(a.getAttribute('href')); - let org, route; - try { org = decodeURIComponent(m?.[1] ?? ''); route = decodeURIComponent(m?.[2] ?? ''); } catch { return; } - if (org !== p.org || !page(route)) return; - e.preventDefault(); - go(p, { org, route }, true); - }); - } - function go(p, next, focusHead) { - p.org = next.org; p.route = next.route; p.html = null; p.project = null; p.title = null; - els(p).body.scrollTop = 0; - writeUrl(); - void drawPane(p, { focusHead }); - } - async function addPane() { - if (!mounted || mounted.panes.length !== 1) return; - const before = document.activeElement; - const [a] = mounted.panes, list = await orgList(); - if (!mounted || mounted.panes.length !== 1) return; - const other = (list || []).find(o => o && !o.invalid && o.id !== a.org && ORG.test(o.id)); - const b = makePane('b', other ? { org: other.id, route: '/organization' } : { org: a.org, route: a.route === '/agents' ? '/organization' : '/agents' }); - mounted.panes.push(b); mounted.show = 'b'; - layout(); paintBar(); writeUrl(); - await drawPane(b); - if (mounted?.panes.includes(b) && mayFocus(b, before)) els(b).pick.focus({ preventScroll: true }); - } - function closePane(p) { - if (!mounted || !p || mounted.panes.length !== 2) return; - clearTimeout(p.timer); p.turn++; - const keep = mounted.panes.find(x => x !== p); - // The remaining pane becomes the first: its page moves into pane a's element. - const a = mounted.panes[0]; - if (keep !== a) { - const b = mounted.panes[1]; - clearTimeout(b.timer); b.turn++; - a.org = keep.org; a.route = keep.route; a.html = null; a.project = null; a.title = keep.title; a.views = keep.views; - } - setHtml(els(mounted.panes[1]).body, ''); mounted.panes[1].html = null; mounted.panes[1].project = null; - mounted.panes = [a]; mounted.show = 'a'; - layout(); paintBar(); writeUrl(); - // The close button just left the page; focus goes to the remaining pane's picker now, and again - // after its draw only if the viewer has not moved elsewhere meanwhile. - els(a).pick.focus({ preventScroll: true }); - const before = document.activeElement; - void drawPane(a).then(() => { if (mounted?.panes[0] === a && mayFocus(a, before)) els(a).pick.focus({ preventScroll: true }); }); - } - function makePane(key, at) { - const el = mounted.root.querySelector(`#pane-${key}`); - return { key, el, org: at.org, route: at.route, turn: 0, timer: null, html: null, project: null, title: null, views: new Map() }; - } - - // ---------- public ---------- - async function mount(container, query, { navigated = false } = {}) { - unmount(); - const gen = ++mountGen; - container.innerHTML = frame(); - const root = container.querySelector('[data-side-by-side]'); - mounted = { root, panes: [], w: 50, show: 'a', gen, observers: [] }; - // projects.js paints a project's content after its own reads; its links are rewritten as they - // land. One observer per pane element for the life of the mount, applying whichever pane object - // holds that element now, so a closed and re-added pane never runs an old organization's rewrite. - const m = mounted; - for (const key of ['a', 'b']) { - const body = root.querySelector(`#pane-${key} [data-pane-body]`); - const observer = new MutationObserver(() => { - if (mounted !== m) return; - const p = m.panes.find(x => x.key === key); - if (p) rewrite(p, body); - }); - observer.observe(body, { childList: true, subtree: true }); - m.observers.push(observer); - } - bind(root); - window.addEventListener('resize', fit); - document.title = 'Side by side · Mosaic Console'; - if (navigated) root.querySelector('h1').focus({ preventScroll: true }); - let wanted = readQuery(query); - if (!wanted.named) { const kept = readQuery(load() || ''); if (kept.a) wanted = { ...kept, named: true }; } - let at = [wanted.a, wanted.b].filter(Boolean); - if (!at.length) at = await defaults(); - if (!mounted || gen !== mountGen) return; - listCache = await orgList(); - if (!mounted || gen !== mountGen) return; - mounted.panes = at.slice(0, 2).map((x, i) => makePane(i ? 'b' : 'a', x)); - mounted.w = Number.isFinite(wanted.w) && wanted.w ? clampW(wanted.w) : 50; - mounted.show = wanted.show === 'b' && mounted.panes.length === 2 ? 'b' : 'a'; - layout(); paintBar(); writeUrl(); - await Promise.all(mounted.panes.map(p => drawPane(p))); - } - function unmount() { - if (!mounted) return; - for (const p of mounted.panes) { clearTimeout(p.timer); p.turn++; } - for (const o of mounted.observers) o.disconnect(); - window.removeEventListener('resize', fit); - mounted = null; mountGen++; - } - // The Console's Refresh: both panes read again now, each keeping its own focus and scroll. - function refresh() { - if (!mounted) return; - projectReads = new Map(); - void orgList(true).then(rows => { if (rows) listCache = rows; }); - for (const p of mounted.panes) void drawPane(p, { fresh: true }); - } - // E2E6 P2-2: a pane showing the organization a write changed reads it again. - document.addEventListener('mosaic-org-changed', e => { - if (!mounted) return; - for (const p of mounted.panes) if (p.org === e.detail?.org) void drawPane(p, { fresh: true }); - }); - // The list the tabs draw from follows each fresh read of the organization list. - document.addEventListener('mosaic-org-list', e => { if (Array.isArray(e.detail?.rows)) listCache = e.detail.rows; }); - - window.MosaicPanes = Object.freeze({ mount, unmount, refresh, page }); -})(); diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs index a995f822b..e92a58943 100644 --- a/packages/webui/src/serve.mjs +++ b/packages/webui/src/serve.mjs @@ -65,12 +65,12 @@ const files = new Map([ ['/', ['index.html', 'text/html; charset=utf-8']], ['/proxy.js', ['proxy.js', 'text/javascript; charset=utf-8']], ['/project-remote.js', ['project-remote.js', 'text/javascript; charset=utf-8']], - ...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'project-archive.js', 'processing.js', 'agents.js', 'federation.js', 'requests.js', 'org-create.js', 'console-restart.js', 'onboarding.js', 'workspace-data.js', 'workspace.js', 'tasks-board.js', 'panes.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]), + ...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'project-archive.js', 'processing.js', 'agents.js', 'federation.js', 'requests.js', 'org-create.js', 'console-restart.js', 'onboarding.js', 'workspace-data.js', 'workspace.js', 'tasks-board.js', 'assistant.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]), ['/files.js', ['files.js', 'text/javascript; charset=utf-8']], ['/projects.js', ['projects.js', 'text/javascript; charset=utf-8']], ['/projects.css', ['projects.css', 'text/css; charset=utf-8']], ['/files.css', ['files.css', 'text/css; charset=utf-8']], - ...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css', 'chat.css', 'project-archive.css', 'processing.css', 'org-create.css', 'onboarding.css', 'tasks-board.css', 'panes.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]), + ...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css', 'chat.css', 'project-archive.css', 'processing.css', 'org-create.css', 'onboarding.css', 'tasks-board.css', 'assistant.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]), ['/display.js', ['display.js', 'text/javascript; charset=utf-8']], ['/inbox.js', ['inbox.js', 'text/javascript; charset=utf-8']], ['/inbox.css', ['inbox.css', 'text/css; charset=utf-8']], diff --git a/packages/webui/tests/assistant-threads-browser.test.mjs b/packages/webui/tests/assistant-threads-browser.test.mjs new file mode 100644 index 000000000..dc66bcef0 --- /dev/null +++ b/packages/webui/tests/assistant-threads-browser.test.mjs @@ -0,0 +1,235 @@ +// 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.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) 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: {} } } }), + }, + 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.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' })), + ], + 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 => 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)})})()`); + 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 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'); + 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(`/Accepted message/.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.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)`); + 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: that pane says so; Alpha's still sends. + 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`); + 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)`); + 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/); +}); + +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, 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'); +}); + +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}`); +}); diff --git a/packages/webui/tests/browser.test.mjs b/packages/webui/tests/browser.test.mjs index eb293555c..a52dbd10a 100644 --- a/packages/webui/tests/browser.test.mjs +++ b/packages/webui/tests/browser.test.mjs @@ -18,8 +18,8 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t assert.equal(await b.evaluate('document.querySelector("#session-count").textContent'), '3 of 4'); await b.evaluate('document.querySelector("#hide-offline").click()'); assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 4); - // Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js. F2 adds onboarding.js after org-create.js, and F3 adds console-restart.js after it. U2 (#1873) adds workspace-data.js and workspace.js before console-restart.js, and U4b adds tasks-board.js after workspace.js. U5 (#1873) adds panes.js (Side by side) just before bus.js, which mounts it. - assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/workspace-data.js', '/workspace.js', '/tasks-board.js', '/console-restart.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/autonomy.js', '/packs.js', '/workflows.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/panes.js', '/bus.js', '/requests.js']); + // Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js. F2 adds onboarding.js after org-create.js, and F3 adds console-restart.js after it. U2 (#1873) adds workspace-data.js and workspace.js before console-restart.js, and U4b adds tasks-board.js after workspace.js. U5 (#1873) added panes.js (Side by side) just before bus.js; SP6 replaces it with assistant.js (the Assistant page), which bus.js mounts. + assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/workspace-data.js', '/workspace.js', '/tasks-board.js', '/console-restart.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/autonomy.js', '/packs.js', '/workflows.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/assistant.js', '/bus.js', '/requests.js']); assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/processing.js"]').length`), 1); assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/chat.js"]').length`), 1); assert.equal(await b.evaluate('!!window.injected'), false); diff --git a/packages/webui/tests/e2e6-layout-browser.test.mjs b/packages/webui/tests/e2e6-layout-browser.test.mjs index b81392cfc..cc9afd132 100644 --- a/packages/webui/tests/e2e6-layout-browser.test.mjs +++ b/packages/webui/tests/e2e6-layout-browser.test.mjs @@ -60,7 +60,9 @@ const org = { bus: { instances: async () => ({ instances: {} }), agents: async () => [], 'thread.claims': async () => [], tasks: async () => [], inbox: async () => [], messages: async () => ({ messages: [] }) }, chat: { projects: async () => [], threads: async () => [] }, }; -for (const [width, route, where] of [[1440, 'orgs/acme/organization', 'the organization page'], [390, 'orgs/acme/organization', 'the organization page'], [1440, 'orgs/acme/side-by-side?a=acme/organization&b=acme/agents', 'a pane'], [390, 'orgs/acme/side-by-side?a=acme/agents&b=acme/organization&show=b', 'a pane']]) { +// SP6 (#1873) removed Side by side, whose panes also held the organization page; the Assistant +// page's panes hold chat threads only, so the organization page itself is the case left. +for (const [width, route, where] of [[1440, 'orgs/acme/organization', 'the organization page'], [390, 'orgs/acme/organization', 'the organization page']]) { test(`E2E6 P2-13: Restart bus and Stop bus line up on ${where} at ${width}`, { timeout: 60000 }, async t => { const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), log: () => {}, organizations: { list: async () => [{ id: 'acme', label: 'Acme', busHealthy: true }], resolve: async () => org } }); t.after(() => close(server)); diff --git a/packages/webui/tests/side-by-side-browser.test.mjs b/packages/webui/tests/side-by-side-browser.test.mjs deleted file mode 100644 index 6f7e3ac90..000000000 --- a/packages/webui/tests/side-by-side-browser.test.mjs +++ /dev/null @@ -1,432 +0,0 @@ -// U5 (#1873, sample 07): Side by side shows two panes, each holding one page of any 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 waitFor = b => 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)})})()`); - -// Three organizations: two running buses and one stopped (home). `state.flip` changes what the -// running organizations report, so a refresh repaints both panes. -function orgs(state = { flip: false, seen: [] }) { - const ORGS = [ - { id: 'ddigital', label: 'Distinctly Digital', theme: 'rosewood', busHealthy: true }, - { id: 'forge', label: 'Forge', theme: 'cobalt', busHealthy: true }, - { id: 'home', label: 'Home', theme: 'grove', busHealthy: false }, - ]; - const PROJECTS = { ddigital: [{ id: 'spring-launch', name: 'Spring launch' }, { id: 'brand-refresh', name: 'Brand refresh' }], forge: [{ id: 'api-gateway', name: 'API gateway' }], home: [] }; - const INSTANCES = { ddigital: ['chief', 'copywriter', 'producer', 'editor', 'researcher', 'designer', 'analyst', 'scheduler'], forge: ['chief', 'builder', 'tester', 'reviewer', 'operator', 'scribe'] }; - const THREADS = { ddigital: [{ id: 't0', name: 'Kickoff notes', project: 'spring-launch', instance: 'chief' }, { id: 't1', name: 'Launch email', project: 'spring-launch', instance: 'copywriter' }], forge: [{ id: 'f0', name: 'Gateway auth', project: 'api-gateway', instance: 'chief' }] }; - const instances = id => ({ business: id, revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {}, - instances: Object.fromEntries(INSTANCES[id].map((n, i) => [n, { definition: n, vars: {}, launched: true, launcher: false, protected: false, kept: false, running: [`r${i}`], alive: [] }])) }); - const agents = id => INSTANCES[id].map((role, i) => ({ role, holder_run: `r${i}`, state: (i % 2 === 0) === state.flip ? 'working' : 'waiting' })); - const business = id => ({ business: { id, human: 'jason', arbiters: ['chief'], projects: PROJECTS[id].map(p => p.id), vars: {}, instances: INSTANCES[id].map(n => ({ instance: n, vars: {} })), appearance: { theme: ORGS.find(o => o.id === id).theme } } }); - return { - list: async () => ORGS, - resolve: async id => { - state.seen.push(id); - const org = ORGS.find(o => o.id === id); if (!org) return null; - if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, chat: { projects: async () => [], threads: async () => [] } }; - return { - busHealthy: true, - reads: { business: async () => business(id), settings: async () => ({ release: '0.0.42', launch: { limits: { total: 4 } } }), queue: async () => ({ rows: [] }), busStatus: async () => ({}) }, - bus: { instances: async () => instances(id), messages: async () => ({ messages: [], unread: 0 }), agents: async () => agents(id), 'thread.claims': async () => [], inbox: async () => [], tasks: async () => [] }, - chat: { projects: async () => PROJECTS[id], threads: async () => THREADS[id] }, - projectFiles: async () => ({ head: 'a'.repeat(40), files: [] }), - }; - }, - }; -} - -async function open(t, { width = 1440, height = 900, hash = '#/orgs/ddigital/side-by-side', state } = {}) { - const server = await startServer({ port: 0, sso: createDevOwner(), organizations: orgs(state), 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); - return { b, base, wait: waitFor(b) }; -} -const text = key => `(document.querySelector("#pane-${key} [data-pane-body]")?.innerText || "")`; -const title = key => `(document.querySelector("#pane-${key} [data-pane-ttl]")?.textContent || "")`; -const query = '(() => Object.fromEntries(new URLSearchParams(location.hash.split("?")[1] || "")))()'; -const pick = (b, key, value) => b.evaluate(`(() => { const s = document.querySelector("#pane-${key}-pick"); 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('side by side stays dark by default under light media, and applying system or light really draws light', async t => { - const { b, wait, base } = await open(t); - await wait(`${title('a')}.length > 0 && ${title('b')}.length > 0`); - await b.evaluate('window.MosaicAppearance.ready.then(() => true)'); - await b.call('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: 'light' }] }); - const drawn = () => b.evaluate(`({ mode: document.documentElement.getAttribute('data-mode'), - palette: document.documentElement.dataset.palette, canvas: getComputedStyle(document.body).backgroundColor, - source: window.MosaicAppearance.state().modeSource })`); - assert.deepEqual(await drawn(), { mode: 'dark', palette: 'rosewood', canvas: 'rgb(26, 20, 23)', source: 'default' }); - for (const mode of ['system', 'light']) { - // U5's corrected shot recipe seeds the per-org key, then loads a fresh document. - const before = await drawn(); - await b.evaluate(`localStorage.setItem('mosaic-console-appearance:ddigital', JSON.stringify({ mode: ${JSON.stringify(mode)} }))`); - assert.deepEqual(await drawn(), before, 'writing storage alone does not apply an override to this document'); - await b.navigate('about:blank'); - await b.navigate(`${base}?appearance=${mode}#/orgs/ddigital/side-by-side`); - await wait(`${title('a')}.length > 0 && ${title('b')}.length > 0`); - await b.evaluate('window.MosaicAppearance.ready.then(() => true)'); - const result = await drawn(); - assert.deepEqual(result, { mode: mode === 'system' ? null : 'light', palette: 'rosewood', - canvas: 'rgb(246, 243, 245)', source: 'browser' }); - t.diagnostic(JSON.stringify({ scheme: 'light', requested: mode, ...result })); - } -}); - -test('the Side by side nav item opens two panes with different organizations, and the URL restores both after a reload', async t => { - const state = { flip: false, seen: [] }; - const { b, base, wait } = await open(t, { hash: '#/orgs/ddigital/organization', state }); - await wait('document.querySelector("[data-org-dashboard]")'); - await b.evaluate('[...document.querySelectorAll("#sections > li > a")].find(a => a.textContent.trim() === "Side by side").click()'); - await wait(`document.querySelectorAll(".panes > .pane:not([hidden])").length === 2 && ${title('a')}.includes("Distinctly Digital") && ${title('b')}.includes("Forge")`); - assert.doesNotMatch(await b.evaluate('document.getElementById("bus-view").innerText'), /Side by side is not built yet/i); - assert.equal(await b.evaluate('document.querySelector("#bus-view h1").textContent'), 'Side by side'); - assert.equal(await b.evaluate('document.title'), 'Side by side · Mosaic Console'); - assert.deepEqual(await b.evaluate(query), { a: 'ddigital/organization', b: 'forge/organization', w: '50', show: 'a' }); - await wait(`${text('a')}.includes("Distinctly Digital") && ${text('b')}.includes("Forge")`); - // Each pane's links name its own organization. - assert.equal(await b.evaluate('[...document.querySelectorAll("#pane-b [data-pane-body] a[href^=\\"#/\\"]")].every(a => a.getAttribute("href").startsWith("#/orgs/forge/"))'), true); - assert.equal(await b.evaluate('document.querySelectorAll("#pane-a [data-pane-body] h1[aria-level=\\"2\\"]").length > 0'), true); - // The picker lists every organization and its projects. - const groups = await b.evaluate('[...document.querySelectorAll("#pane-b-pick optgroup")].map(g => g.label)'); - assert.deepEqual(groups, ['Distinctly Digital', 'Forge', 'Home · bus unavailable']); - assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#pane-b-pick option")].map(o => o.value).filter(Boolean)'), - ['ddigital/organization', 'ddigital/agents', 'ddigital/projects/spring-launch', 'ddigital/projects/brand-refresh', 'forge/organization', 'forge/agents', 'forge/projects/api-gateway', 'home/organization', 'home/agents']); - // Pick a project in the right pane; the left keeps its page. - await pick(b, 'b', 'forge/projects/api-gateway'); - await wait(`${title('b')}.startsWith("API gateway") && ${text('b')}.includes("API gateway") && location.hash.includes("b=forge/projects/api-gateway")`); - assert.match(await b.evaluate(title('a')), /^Dashboard/); - // A project tab opens in the pane, not the full view. - await b.evaluate('[...document.querySelectorAll("#pane-b nav.project-tabs a")].find(a => /Tasks/.test(a.textContent)).click()'); - await wait(`location.hash.includes("b=forge/projects/api-gateway/tasks") && ${title('b')}.includes("Tasks")`); - assert.match(await b.evaluate('location.hash'), /^#\/orgs\/ddigital\/side-by-side\?/); - // Reload: both panes come back from the URL. - await b.call('Page.reload'); - await wait('document.readyState === "complete"'); - await wait(`${title('a')}.startsWith("Dashboard") && ${title('a')}.includes("Distinctly Digital") && ${title('b')}.includes("API gateway") && ${title('b')}.includes("Tasks")`); - assert.deepEqual(await b.evaluate(query), { a: 'ddigital/organization', b: 'forge/projects/api-gateway/tasks', w: '50', show: 'a' }); - // A shared link opens exactly what it names, whatever this browser remembered. - await b.navigate(`${base}#/orgs/forge/side-by-side?a=forge/agents&b=ddigital/projects/spring-launch&w=40&show=b`); - await wait(`${title('a')}.startsWith("Agents") && ${title('a')}.includes("Forge") && ${title('b')}.startsWith("Spring launch")`); - assert.equal(await b.evaluate('document.querySelector("[data-sbs-divider]").getAttribute("aria-valuenow")'), '40'); - // A link with a route a pane does not draw falls back to the remembered layout or the default. - await b.navigate(`${base}#/orgs/ddigital/side-by-side?a=ddigital/settings&b=nope!/organization`); - await wait(`document.querySelectorAll(".panes > .pane:not([hidden])").length === 2 && ${title('a')}.length > 0 && !location.hash.includes("settings")`); - // Leaving the page stops both panes reading. - await b.evaluate('location.hash = "#/orgs/ddigital/agents"'); - await wait('!document.querySelector("[data-side-by-side]") && [...document.querySelectorAll("#bus-view h1")].some(h => h.textContent.trim() === "Agents")'); - assert.ok(state.seen.includes('forge') && state.seen.includes('ddigital')); -}); - -test('the divider resizes the panes by mouse and by keyboard', async t => { - const { b, wait } = await open(t); - await wait(`${title('a')}.length > 0 && ${title('b')}.length > 0`); - const sep = '[role=separator][data-sbs-divider]'; - assert.deepEqual(await b.evaluate(`(() => { const d = document.querySelector("${sep}"); return [d.getAttribute("aria-valuenow"), d.getAttribute("aria-valuemin"), d.getAttribute("aria-valuemax"), d.getAttribute("aria-orientation"), d.tabIndex]; })()`), ['50', '25', '75', 'vertical', 0]); - const ratio = `(() => { const a = document.getElementById("pane-a").getBoundingClientRect().width, b = document.getElementById("pane-b").getBoundingClientRect().width; return Math.round(a / (a + b) * 100); })()`; - // Mouse: drag the divider to 30% of the panes' width. - const box = await b.evaluate(`(() => { const p = document.querySelector("[data-sbs-panes]").getBoundingClientRect(), d = document.querySelector("${sep}").getBoundingClientRect(); return { left: p.left, width: p.width, x: d.left + d.width / 2, y: d.top + 200 }; })()`); - const target = box.left + box.width * 0.3; - await b.call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: box.x, y: box.y }); - await b.call('Input.dispatchMouseEvent', { type: 'mousePressed', x: box.x, y: box.y, button: 'left', buttons: 1, clickCount: 1 }); - for (const step of [0.25, 0.5, 0.75, 1]) await b.call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: box.x + (target - box.x) * step, y: box.y, button: 'left', buttons: 1 }); - await b.call('Input.dispatchMouseEvent', { type: 'mouseReleased', x: target, y: box.y, button: 'left', buttons: 0, clickCount: 1 }); - await wait(`document.querySelector("${sep}").getAttribute("aria-valuenow") === "30" && location.hash.includes("w=30")`); - assert.ok(Math.abs(await b.evaluate(ratio) - 30) <= 1); - // Keyboard: the separator has focus after the drag; arrows step by 5, Home and End reach the limits. - assert.equal(await b.evaluate('document.activeElement.getAttribute("role")'), 'separator'); - await b.call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 }); - await b.call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 }); - await wait(`document.querySelector("${sep}").getAttribute("aria-valuenow") === "35"`); - await b.call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 }); - await b.call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 }); - await b.call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 }); - await b.call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 }); - await wait(`document.querySelector("${sep}").getAttribute("aria-valuenow") === "25"`); - await b.call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 }); - await b.call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 }); - assert.equal(await b.evaluate(`document.querySelector("${sep}").getAttribute("aria-valuenow")`), '25'); - await b.call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'End', code: 'End', windowsVirtualKeyCode: 35 }); - await b.call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'End', code: 'End', windowsVirtualKeyCode: 35 }); - await wait(`document.querySelector("${sep}").getAttribute("aria-valuenow") === "75" && location.hash.includes("w=75")`); - assert.ok(Math.abs(await b.evaluate(ratio) - 75) <= 1); - // Closing a pane goes back to one; the divider leaves with it. Add pane brings a second back. - await b.evaluate('document.querySelector("#pane-a [data-pane-close]").click()'); - await wait(`document.getElementById("pane-b").hidden && document.querySelector("${sep}").hidden && !new URLSearchParams(location.hash.split("?")[1]).has("b") && ${title('a')}.includes("Forge")`); - assert.equal(await b.evaluate('document.activeElement.id'), 'pane-a-pick'); - assert.equal(await b.evaluate('document.querySelector("#pane-a [data-pane-close]").hidden'), true); - assert.match(await b.evaluate('document.querySelector("[data-sbs-note]").textContent'), /^1 pane ·/); - await b.evaluate('document.querySelector("[data-pane-add]").click()'); - await wait(`!document.getElementById("pane-b").hidden && ${title('b')}.length > 0 && new URLSearchParams(location.hash.split("?")[1]).has("b")`); - assert.equal(await b.evaluate('document.querySelector("[data-pane-add]").disabled'), true); -}); - -test('each pane keeps its own focus and scroll through a refresh', async t => { - const state = { flip: false, seen: [] }; - const { b, wait } = await open(t, { height: 520, state, hash: '#/orgs/ddigital/side-by-side?a=ddigital/agents&b=forge/agents' }); - await wait(`${text('a')}.includes("copywriter") && ${text('b')}.includes("builder")`); - const scrollable = await b.evaluate('[...document.querySelectorAll("[data-pane-body]")].map(el => el.scrollHeight - el.clientHeight)'); - assert.ok(scrollable.every(n => n > 60), `both panes scroll: ${scrollable}`); - const focused = await b.evaluate('(() => { const a = document.querySelector("#pane-a [data-pane-body] a[href]"); a.focus(); return a.getAttribute("href"); })()'); - await b.evaluate('document.querySelector("#pane-a [data-pane-body]").scrollTop = 40; document.querySelector("#pane-b [data-pane-body]").scrollTop = 60;'); - const before = await b.evaluate(`[${text('a')}, ${text('b')}]`); - state.flip = true; - await b.evaluate('document.getElementById("refresh").click()'); - await wait(`${text('a')} !== ${JSON.stringify(before[0])} && ${text('b')} !== ${JSON.stringify(before[1])}`); - assert.equal(await b.evaluate('document.querySelector("#pane-a").contains(document.activeElement) && document.activeElement.getAttribute("href")'), focused); - assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-pane-body]")].map(el => el.scrollTop)'), [40, 60]); - // Focus in the right pane stays there while the left repaints. - const right = await b.evaluate('(() => { const a = document.querySelector("#pane-b [data-pane-body] a[href]"); a.focus({ preventScroll: true }); return a.getAttribute("href"); })()'); - state.flip = false; - await b.evaluate('document.getElementById("refresh").click()'); - await wait(`${text('a')} === ${JSON.stringify(before[0])}`); - assert.equal(await b.evaluate('document.querySelector("#pane-b").contains(document.activeElement) && document.activeElement.getAttribute("href")'), right); - assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-pane-body]")].map(el => el.scrollTop)'), [40, 60]); -}); - -test('a pane whose organization is down shows its state and the other pane still works', async t => { - const { b, wait } = await open(t, { hash: '#/orgs/ddigital/side-by-side?a=home/organization&b=forge/organization' }); - await wait(`${title('a')}.includes("Home") && ${title('b')}.includes("Forge") && ${text('b')}.includes("Forge")`); - await wait('/Home: bus unavailable/.test(document.querySelector("#pane-a [data-pane-line]").textContent)'); - assert.equal(await b.evaluate('document.querySelector("#pane-a [data-pane-line]").getAttribute("role")'), 'status'); - assert.equal(await b.evaluate('document.querySelector("#pane-a [data-pane-state]").innerText.trim().length > 0'), true); - assert.equal(await b.evaluate('document.querySelector("#pane-b [data-pane-line]").hidden'), true); - await pick(b, 'b', 'forge/agents'); - await wait(`${title('b')}.startsWith("Agents") && ${text('b')}.includes("builder")`); - await pick(b, 'b', 'forge/projects/api-gateway'); - await wait(`${title('b')}.startsWith("API gateway") && ${text('b')}.includes("API gateway")`); - assert.match(await b.evaluate(title('a')), /Home/); - // An organization that is not on the list is one honest line; nothing is read for it. - await b.evaluate('location.hash = "#/orgs/ddigital/side-by-side?a=ghost/organization&b=forge/agents"'); - await wait(`/ghost is not on your organization list/.test(document.querySelector("#pane-a [data-pane-line]").textContent) && ${text('b')}.includes("builder")`); -}); - -test('on a phone one pane shows at a time, switched from the bar, with no horizontal scroll', async t => { - const { b, wait } = await open(t, { width: 390, height: 844 }); - await wait(`${title('a')}.length > 0 && ${title('b')}.length > 0 && ${text('a')}.length > 0`); - const visible = '[...document.querySelectorAll(".panes > .pane")].filter(p => p.getBoundingClientRect().height > 0).map(p => p.id)'; - assert.deepEqual(await b.evaluate(visible), ['pane-a']); - assert.notEqual(await b.evaluate('getComputedStyle(document.querySelector("[data-sbs-tabs]")).display'), 'none'); - assert.equal(await b.evaluate('getComputedStyle(document.querySelector("[data-sbs-divider]")).display'), 'none'); - assert.equal(await b.evaluate('getComputedStyle(document.querySelector("[data-pane-add]")).display'), 'none'); - assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-sbs-tabs] button")].map(x => x.getAttribute("aria-pressed"))'), ['true', 'false']); - assert.ok(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), 'no horizontal scroll'); - await b.evaluate('document.querySelector("[data-sbs-tabs] [data-pane-show=b]").click()'); - await wait(`JSON.stringify(${visible}) === '["pane-b"]' && location.hash.includes("show=b")`); - assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-sbs-tabs] button")].map(x => x.getAttribute("aria-pressed"))'), ['false', 'true']); - assert.ok(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), 'no horizontal scroll on the second pane'); - await pick(b, 'b', 'ddigital/projects/spring-launch'); - await wait(`${text('b')}.includes("Spring launch")`); - assert.ok(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), 'no horizontal scroll on a project page'); -}); - -// Review fixes (#1873 U5 review, findings 1-4). `hold(url)` makes the page's next read of that URL -// wait until `release()`; the test then changes the pane while the read is pending. -const hold = url => `(() => { const real = window.fetch; window.held = { url: ${JSON.stringify(url)}, pending: false }; - window.fetch = async (u, o) => { if (window.held?.url === u && !window.held.done) { window.held.pending = true; await new Promise(r => { window.held.release = () => { window.held.done = true; r(); }; }); } return real(u, o); }; })()`; -const settled = b => b.evaluate('new Promise(r => setTimeout(r, 400))'); - -test('a pane closed and opened again rewrites new links for its current organization only', async t => { - const { b, wait } = await open(t); - await wait(`${title('b')}.includes("Forge") && document.querySelector("#pane-b-pick")?.options.length > 4`); - await b.evaluate('document.querySelector("#pane-b [data-pane-close]").click()'); - await wait('document.querySelector("#pane-b").hidden && !document.querySelector("[data-pane-add]").disabled'); - await b.evaluate('document.querySelector("[data-pane-add]").click()'); - await wait(`!document.querySelector("#pane-b").hidden && ${title('b')}.length > 0`); - await pick(b, 'b', 'ddigital/organization'); - await wait(`${title('b')}.includes("Distinctly Digital") && location.hash.includes("b=ddigital/organization")`); - // Content a page appends after its own reads (projects.js does this) gets the pane's organization. - await b.evaluate(`document.querySelector("#pane-b [data-pane-body]").insertAdjacentHTML("beforeend", 'latefile')`); - await wait('document.querySelector("#pane-b [data-late]").getAttribute("href").startsWith("#/orgs/")'); - assert.equal(await b.evaluate('document.querySelector("#pane-b [data-late]").getAttribute("href")'), '#/orgs/ddigital/docs/example'); - assert.equal(await b.evaluate('document.querySelector("#pane-b [data-late-api]").getAttribute("href")'), '/api/orgs/ddigital/files/x'); - // Leaving the page stops every pane observer: later content in the old element is left alone. - await b.evaluate('location.hash = "#/orgs/ddigital/agents"'); - await wait('!document.querySelector("[data-side-by-side]")'); -}); - -test('a slow project read never paints over the page or organization chosen since', async t => { - const { b, wait } = await open(t); - await wait(`${title('b')}.includes("Forge") && document.querySelector("#pane-b-pick")?.options.length > 4`); - // Page change while the project's thread read is pending. - await b.evaluate(hold('/api/orgs/forge/chat/threads')); - await pick(b, 'b', 'forge/projects/api-gateway'); - await wait('window.held.pending'); - await pick(b, 'b', 'forge/agents'); - await wait(`${title('b')}.startsWith("Agents") && ${text('b')}.includes("builder")`); - await b.evaluate('window.held.release()'); - await settled(b); - assert.equal(await b.evaluate(`${text('b')}.includes("API gateway")`), false, 'the old project did not replace Agents'); - assert.match(await b.evaluate(title('b')), /^Agents/); - // Organization change while the read is pending: the old organization's project is not painted. - await b.evaluate(hold('/api/orgs/forge/chat/threads')); - await pick(b, 'b', 'forge/projects/api-gateway'); - await wait('window.held.pending'); - await pick(b, 'b', 'ddigital/projects/spring-launch'); - await wait(`${title('b')}.startsWith("Spring launch") && ${text('b')}.includes("Spring launch")`); - await b.evaluate('window.held.release()'); - await settled(b); - assert.equal(await b.evaluate(`${text('b')}.includes("API gateway")`), false, 'the old organization did not replace Spring launch'); - assert.equal(await b.evaluate('[...document.querySelectorAll("#pane-b [data-pane-body] a[href^=\\"#/orgs/\\"]")].every(a => a.getAttribute("href").startsWith("#/orgs/ddigital/"))'), true); -}); - -test('the same project in both panes keeps a request choice per pane', async t => { - const { b, wait } = await open(t); - await wait(`${title('b')}.includes("Forge")`); - const { stub, P } = await import('./helpers/projects-review-fixture.mjs'); - // The fixture answers the project reads of both organizations; the organization list stays real. - await b.evaluate(stub.replace('const path=String(url),method', `if(url==='/api/orgs'||url==='/api/themes')return real(url,init);const path=String(url).replace(/^\\/api\\/orgs\\/(?:ddigital|forge)\\//,'/api/'),method`)); - await b.evaluate('window.MosaicPanes.refresh()'); - await wait(`document.querySelector('#pane-a-pick option[value="ddigital/projects/${P}"]')`); - await b.evaluate(`location.hash = '#/orgs/ddigital/side-by-side?a=ddigital/projects/${P}/requirements&b=forge/projects/${P}/requirements'`); - const values = `[...document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]')].map(e => e.value)`; - await wait(`document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]').length === 2`); - assert.deepEqual(await b.evaluate(values), ['request-1', 'request-1']); - await b.evaluate(`(() => { const e = document.querySelector('#pane-a-project-request-select'); e.value = 'request-2'; e.dispatchEvent(new Event('change', { bubbles: true })); })()`); - await wait(`${values}[0] === 'request-2'`); - // The redrawn select gets focus back through the pane's own id. - assert.equal(await b.evaluate('document.activeElement.id'), 'pane-a-project-request-select'); - await b.evaluate('window.MosaicPanes.refresh()'); - await settled(b); - await wait(`document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]').length === 2`); - assert.deepEqual(await b.evaluate(values), ['request-2', 'request-1']); -}); - -test('a pane keeps its request choice and PRD disclosure when a new conversation repaints the project', async t => { - const { b, wait } = await open(t); - await wait(`${title('b')}.includes("Forge")`); - const { stub, P } = await import('./helpers/projects-review-fixture.mjs'); - await b.evaluate(stub.replace('const path=String(url),method', `if(url==='/api/orgs'||url==='/api/themes')return real(url,init);const path=String(url).replace(/^\\/api\\/orgs\\/(?:ddigital|forge)\\//,'/api/'),method`)); - await b.evaluate('window.MosaicPanes.refresh()'); - await wait(`document.querySelector('#pane-a-pick option[value="ddigital/projects/${P}"]')`); - // The same organization and project in both panes. - await b.evaluate(`location.hash = '#/orgs/ddigital/side-by-side?a=ddigital/projects/${P}/requirements&b=ddigital/projects/${P}/requirements'`); - await wait(`document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]').length === 2 && document.querySelectorAll('[data-pane-body] .project-prd-toggle').length === 2`); - const state = `({ selected: [...document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]')].map(e => e.value), expanded: [...document.querySelectorAll('[data-pane-body] [data-prd]')].map(e => e.classList.contains('expanded')) })`; - await b.evaluate(`(() => { const e = document.querySelector('#pane-a-project-request-select'); e.value = 'request-2'; e.dispatchEvent(new Event('change', { bubbles: true })); })()`); - await wait(`document.querySelector('#pane-a-project-request-select').value === 'request-2'`); - await b.evaluate(`document.querySelector('#pane-a .project-prd-toggle').click()`); - await wait(`document.querySelector('#pane-a [data-prd]')?.classList.contains('expanded')`); - // A new conversation changes the header's count, so each pane replaces its project content. - const before = await b.evaluate(`document.querySelector('#pane-a .project-header p')?.textContent || ''`); - await b.evaluate(`window.fixtureThreads.push({ ...window.fixtureThreads[0], id: '20000000-0000-4000-8000-000000000004', name: 'A new conversation' }); window.MosaicPanes.refresh()`); - await wait(`(() => { const t = document.querySelector('#pane-a .project-header p')?.textContent || ''; return t !== ${JSON.stringify(before)} && /4 conversations/.test(t) && /4 conversations/.test(document.querySelector('#pane-b .project-header p')?.textContent || ''); })()`); - await settled(b); - await wait(`document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]').length === 2 && document.querySelectorAll('[data-pane-body] [data-prd]').length === 2`); - assert.deepEqual(await b.evaluate(state), { selected: ['request-2', 'request-1'], expanded: [true, false] }); -}); - -test('a pane navigation that finishes late does not take focus from the other pane', async t => { - const { b, wait } = await open(t); - await wait(`document.querySelector('#pane-a [data-focus-key="agents-all"]') && document.querySelector('#pane-b-pick')?.options.length > 4`); - await b.evaluate(hold('/api/orgs/ddigital/bus/instances')); - await b.evaluate(`document.querySelector('#pane-a [data-focus-key="agents-all"]').click()`); - await wait('window.held.pending'); - await b.evaluate(`document.querySelector('#pane-b-pick').focus(); window.held.release()`); - await wait(`${title('a')}.startsWith('Agents')`); - await settled(b); - assert.equal(await b.evaluate('document.activeElement.id'), 'pane-b-pick'); - // Without a move elsewhere, the same navigation still lands on the new page's heading. - await pick(b, 'a', 'ddigital/organization'); - await wait(`document.querySelector('#pane-a [data-focus-key="agents-all"]')`); - await b.evaluate(`document.querySelector('#pane-a [data-focus-key="agents-all"]').click()`); - await wait(`${title('a')}.startsWith('Agents') && document.activeElement?.tagName === 'H1' && document.querySelector('#pane-a').contains(document.activeElement)`); -}); - -// A1's "Available in a project" chooser, with an Agents pane per organization: each pane's choice -// redraws its own table and survives a refresh, keyed by that pane's organization. -test('each Agents pane keeps its own project choice for its organization', async t => { - const { b, wait } = await open(t, { hash: '#/orgs/ddigital/side-by-side?a=ddigital/agents&b=forge/agents' }); - const a = (name, level = 'org', definition = name) => ({ name, level, definition, model: null, scope: { kind: 'org' }, overrides: [], narrowed: [] }); - const set = agents => ({ state: 'ok', agents, excluded: [] }); - const levels = { root: { state: 'ok', assistant: null, agents: [] }, orgs: [ - { id: 'ddigital', state: 'ok', base: set([a('chief')]), defined: { 'spring-launch': [{ name: 'social-scheduler', form: 'define' }] }, - projects: { 'spring-launch': set([a('chief'), a('social-scheduler', 'project', 'coder')]), 'brand-refresh': set([a('chief')]) } }, - { id: 'forge', state: 'ok', base: set([a('chief')]), defined: { 'api-gateway': [{ name: 'load-tester', form: 'define' }] }, - projects: { 'api-gateway': set([a('chief'), a('load-tester', 'project', 'coder')]), ops: set([a('chief')]) } }, - ] }; - await b.evaluate(`(() => { const real = window.fetch; window.fetch = (url, init) => String(url).endsWith('/api/agents/levels') ? Promise.resolve(new Response(${JSON.stringify(JSON.stringify(levels))}, { headers: { 'content-type': 'application/json' } })) : real(url, init); })()`); - await b.evaluate('window.MosaicPanes.refresh()'); - const select = key => `document.querySelector('#pane-${key} [data-agents-project]')`; - const rows = key => `[...document.querySelectorAll('#pane-${key} [data-agents-available] tr[data-agent]')].map(r => r.dataset.agent)`; - await wait(`${select('a')} && ${select('b')} && ${rows('a')}.includes('social-scheduler') && ${rows('b')}.includes('load-tester')`); - await b.evaluate(`(() => { const s = ${select('b')}; s.value = 'ops'; s.dispatchEvent(new Event('change', { bubbles: true })); })()`); - await wait(`!${rows('b')}.includes('load-tester')`); - assert.equal(await b.evaluate(`${rows('a')}.includes('social-scheduler')`), true); - assert.equal(await b.evaluate(`${rows('a')}.includes('load-tester')`), false); - await b.evaluate('window.MosaicPanes.refresh()'); - await settled(b); - await wait(`${select('a')} && ${select('b')}`); - assert.deepEqual(await b.evaluate(`[${select('a')}.value, ${select('b')}.value]`), ['spring-launch', 'ops']); - assert.equal(await b.evaluate(`!${rows('b')}.includes('load-tester') && ${rows('a')}.includes('social-scheduler')`), true); -}); - -// Polish 1 (#1873): every id a pane draws is its own. With the same page in both panes no id -// appears twice, and every label, ARIA reference and in-page link resolves inside its own pane. -const idAudit = `(() => { - const count = new Map(); - for (const el of document.querySelectorAll('[id]')) count.set(el.id, (count.get(el.id) || 0) + 1); - const duplicates = [...count].filter(([, n]) => n > 1).map(([id]) => id).sort(); - const stray = []; - for (const pane of document.querySelectorAll('.panes > .pane:not([hidden])')) { - for (const attr of ['for', 'aria-labelledby', 'aria-describedby', 'aria-controls']) { - for (const el of pane.querySelectorAll('[' + attr + ']')) { - for (const id of el.getAttribute(attr).split(/\\s+/).filter(Boolean)) { - const target = document.getElementById(id); - if (!target || !pane.contains(target)) stray.push(pane.id + ' ' + attr + '=' + id); - } - } - } - for (const a of pane.querySelectorAll('a[href^="#"]:not([href^="#/"])')) { - const id = decodeURIComponent(a.getAttribute('href').slice(1)); - const target = id && document.getElementById(id); - if (id && (!target || !pane.contains(target))) stray.push(pane.id + ' href=#' + id); - } - } - return { duplicates, stray: stray.sort() }; -})()`; - -test('the same project, dashboard and Agents page in both panes draw no duplicate ids, and every reference stays in its pane', async t => { - const { b, wait } = await open(t); - await wait(`${title('b')}.includes("Forge")`); - const { stub, P } = await import('./helpers/projects-review-fixture.mjs'); - await b.evaluate(stub.replace('const path=String(url),method', `if(url==='/api/orgs'||url==='/api/themes')return real(url,init);const path=String(url).replace(/^\\/api\\/orgs\\/(?:ddigital|forge)\\//,'/api/'),method`)); - const a = (name, level = 'org', definition = name) => ({ name, level, definition, model: null, scope: { kind: 'org' }, overrides: [], narrowed: [] }); - const set = agents => ({ state: 'ok', agents, excluded: [] }); - const levels = { root: { state: 'ok', assistant: null, agents: [] }, orgs: [ - { id: 'ddigital', state: 'ok', base: set([a('chief')]), defined: { [P]: [{ name: 'social-scheduler', form: 'define' }] }, - projects: { [P]: set([a('chief'), a('social-scheduler', 'project', 'coder')]) } }, - ] }; - await b.evaluate(`(() => { const real = window.fetch; window.fetch = (url, init) => String(url).endsWith('/api/agents/levels') ? Promise.resolve(new Response(${JSON.stringify(JSON.stringify(levels))}, { headers: { 'content-type': 'application/json' } })) : real(url, init); })()`); - await b.evaluate('window.MosaicPanes.refresh()'); - await wait(`document.querySelector('#pane-a-pick option[value="ddigital/projects/${P}"]')`); - const both = route => `location.hash = '#/orgs/ddigital/side-by-side?a=ddigital${route}&b=ddigital${route}'`; - const pages = [ - ['/organization', `${title('a')}.startsWith('Dashboard') && ${title('b')}.startsWith('Dashboard') && document.querySelectorAll('[data-pane-body] [data-org-settings]').length === 2`], - ['/agents', `${title('a')}.startsWith('Agents') && ${title('b')}.startsWith('Agents') && document.querySelectorAll('[data-pane-body] [data-agents-available] tr[data-agent]').length >= 2`], - [`/projects/${P}`, `${title('a')}.startsWith('Pomodoro Timer') && ${title('b')}.startsWith('Pomodoro Timer') && !${text('a')}.includes('Reading…') && !${text('b')}.includes('Reading…')`], - [`/projects/${P}/conversations`, `${title('a')}.includes('Conversations') && ${title('b')}.includes('Conversations')`], - [`/projects/${P}/tasks`, `${title('a')}.includes('Tasks') && ${title('b')}.includes('Tasks') && !${text('a')}.includes('Reading…') && !${text('b')}.includes('Reading…')`], - [`/projects/${P}/requirements`, `document.querySelectorAll('[data-pane-body] [data-prd]').length === 2 && document.querySelectorAll('[data-pane-body] select').length >= 2`], - [`/projects/${P}/files`, `${title('a')}.includes('Files') && ${title('b')}.includes('Files') && !${text('a')}.includes('Reading…') && !${text('b')}.includes('Reading…')`], - ]; - const found = {}, drawn = {}; - for (const [route, ready] of pages) { - await b.evaluate(both(route)); - await wait(ready); - await settled(b); - const page = route.replace(P, 'P'); - found[page] = await b.evaluate(idAudit); - drawn[page] = await b.evaluate(`[...document.querySelectorAll('#pane-a [data-pane-body] [id]')].map(e => e.id)`); - } - assert.deepEqual(found, Object.fromEntries(pages.map(([route]) => [route.replace(P, 'P'), { duplicates: [], stray: [] }]))); - // The audit is not vacuous: the dashboard draws its reload note and the requirements page its - // request select, north star and PRD headings, each with the pane's prefix. - assert.deepEqual(drawn['/organization'], ['pane-a-ws-reload-why']); - assert.deepEqual(drawn['/projects/P/requirements'], ['pane-a-project-request-select', 'pane-a-project-north-star', 'pane-a-project-prd']); -}); diff --git a/packages/webui/tests/unified-workspace-browser.test.mjs b/packages/webui/tests/unified-workspace-browser.test.mjs index b11aa6699..52d6fdec1 100644 --- a/packages/webui/tests/unified-workspace-browser.test.mjs +++ b/packages/webui/tests/unified-workspace-browser.test.mjs @@ -97,7 +97,7 @@ test('sidebar follows sample 01 and Queue opens from the organization dashboard, await b.viewport(1440, 900); await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/organization`); await wait('document.querySelector("[data-org-dashboard] [data-org-settings]") && document.querySelectorAll("#workspace-nav .ws-proj").length === 3'); - assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#sections > li > a .t")].map(e => e.textContent)'), ['Dashboard', 'Inbox', 'Tasks', 'Projects', 'Side by side', 'Agents', 'Assistant']); + assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#sections > li > a .t")].map(e => e.textContent)'), ['Dashboard', 'Inbox', 'Tasks', 'Projects', 'Agents', 'Assistant']); assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#sections-foot a")].map(e => e.textContent.trim())'), ['Settings']); assert.match(await b.evaluate('document.querySelector("[data-org-toggle]").textContent.replace(/\\s+/g, " ").trim()'), /^Organizations\s*3$/); assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-workspace-org]")].map(li => [li.dataset.workspaceOrg, !!li.querySelector(".ws-av"), [...li.querySelector(".ws-dot").classList].find(c => c.startsWith("s-"))])'), @@ -119,7 +119,8 @@ test('sidebar follows sample 01 and Queue opens from the organization dashboard, await b.evaluate('document.querySelector("[data-org-queue]").click()'); await wait('location.hash === "#/orgs/ddigital/queue" && [...document.querySelectorAll("#bus-view h1")].some(h => h.textContent.trim() === "Queue")'); // Every sidebar view is built: Inbox (U3, workspace-pages-browser.test.mjs), Tasks (U4b, - // tasks-board-browser.test.mjs) and Side by side (U5, side-by-side-browser.test.mjs). + // tasks-board-browser.test.mjs) and Assistant (SP6, assistant-threads-browser.test.mjs), which + // replaced Side by side. }); test('organization dashboard renders every section from the per-organization reads', async t => {