feat(webui): the Assistant page holds up to three threads from any organization side by side, Side by side removed (#1873) #1970
No files matched your search
@@ -0,0 +1,78 @@
|
||||
/* #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 .ws-btn.primary{background:var(--action);color:var(--onAction);border-color:var(--action)}
|
||||
html[data-organizations] .assist-new .ws-btn.primary:hover:not(:disabled){background:color-mix(in srgb,var(--action) 88%,var(--strong))}
|
||||
html[data-organizations] .assist-new [data-assist-new-status]{margin:0;font-size:13px}
|
||||
html[data-organizations] .assist-new [data-assist-new-status]:empty{display:none}
|
||||
|
||||
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)}
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
// #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=<org>:<thread>,…&show=<org>:<thread>), 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 => `<svg class="i sm" viewBox="0 0 24 24" aria-hidden="true" focusable="false">${d}</svg>`;
|
||||
const CLOSE = svg('<path d="M6 6l12 12M18 6 6 18"/>');
|
||||
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);
|
||||
|
||||
// "<org>:<thread>,…" 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 want = q.get('show') || '', at = want.indexOf(':');
|
||||
const show = at > 0 ? `${want.slice(0, at)}:${want.slice(at + 1).toLowerCase()}` : null;
|
||||
return { keys, show: keys.includes(show) ? show : null, named: q.has('open') };
|
||||
}
|
||||
const split = key => { const cut = key.indexOf(':'); return { org: key.slice(0, cut), id: key.slice(cut + 1) }; };
|
||||
|
||||
// ---------- 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 `<section class="assist" data-assistant aria-labelledby="assist-title">
|
||||
<div class="assist-bar">
|
||||
<div class="assist-vh"><h1 id="assist-title" tabindex="-1">Assistant</h1>
|
||||
<p class="sub">Threads from any organization, side by side. Each pane reads and sends through its own organization.</p></div>
|
||||
<div class="assist-tools">
|
||||
<label class="ws-sr" for="assist-open">Open thread</label>
|
||||
<select id="assist-open" class="assist-open" data-assist-open><option value="">Open thread…</option></select>
|
||||
<a class="ws-btn" data-assist-messages href="#/messages">Messages</a>
|
||||
</div>
|
||||
</div>
|
||||
<div data-assist-unread></div>
|
||||
<p class="note" data-assist-note role="status"></p>
|
||||
<form class="assist-new" data-assist-new hidden aria-labelledby="assist-new-title">
|
||||
<h2 id="assist-new-title" tabindex="-1">New thread in <span data-assist-new-org></span></h2>
|
||||
<div class="assist-new-fields">
|
||||
<label><span>Project</span><select name="project" required></select></label>
|
||||
<label><span>Agent</span><select name="instance" required></select></label>
|
||||
<label><span>Name</span><input name="name" required maxlength="200" autocomplete="off"></label>
|
||||
</div>
|
||||
<div class="assist-new-actions"><button class="ws-btn primary" type="submit">Start thread</button><button class="ws-btn" type="button" data-assist-new-cancel>Cancel</button></div>
|
||||
<p class="note" data-assist-new-status role="status"></p>
|
||||
</form>
|
||||
<div class="ws-seg assist-tabs" role="group" aria-label="Shown thread" data-assist-tabs></div>
|
||||
<div class="assist-panes" data-assist-panes></div>
|
||||
<p class="assist-empty" data-assist-empty hidden>No thread is open. Choose one under Open thread.</p>
|
||||
</section>`;
|
||||
}
|
||||
function paneShell(p) {
|
||||
const n = p.n;
|
||||
return `<section class="assist-pane ws-o" data-assist-pane="${esc(p.key)}" id="assist-p${n}" aria-labelledby="assist-p${n}-title">
|
||||
<div class="assist-pane-h">
|
||||
<h2 id="assist-p${n}-title" data-assist-title>Reading…</h2>
|
||||
<span class="assist-chips" data-assist-chips></span>
|
||||
<button type="button" class="pane-tool" data-assist-close aria-label="Close thread">${CLOSE}</button>
|
||||
</div>
|
||||
<p class="assist-line" data-assist-line role="status" hidden></p>
|
||||
<div class="assist-body" data-assist-body></div>
|
||||
</section>`;
|
||||
}
|
||||
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 `<optgroup label="${esc(name)} · invalid"><option disabled value="">Organization invalid</option></optgroup>`;
|
||||
if (o.busHealthy === false) return `<optgroup label="${esc(name)} · ${esc(ws().state(o)[2])}"><option disabled value="">No threads while its bus is down</option></optgroup>`;
|
||||
if (!i?.threads) return `<optgroup label="${esc(name)} · not answering"><option disabled value="">Threads unavailable</option></optgroup>`;
|
||||
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 `<option value="${esc(key)}"${open.has(key) ? ' disabled' : ''}>${esc(t.name || t.id)}${where ? ` · ${esc(where)}` : ''}${open.has(key) ? ' (open)' : ''}</option>`;
|
||||
}).join('');
|
||||
const fresh = i.chatSend ? `<option value="new:${esc(o.id)}">New thread in ${esc(name)}…</option>` : '';
|
||||
return `<optgroup label="${esc(name)}">${rows || (fresh ? '' : '<option disabled value="">No threads</option>')}${fresh}</optgroup>`;
|
||||
});
|
||||
return `<option value="">Open thread…</option>${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') : '<p class="note ws-unread-note">The organization list could not be read. Open panes only read.</p>');
|
||||
setText(root.querySelector('[data-assist-note]'), panes.length ? `${panes.length} open, up to ${MAX}` : '');
|
||||
const pick = root.querySelector('[data-assist-open]');
|
||||
if (document.activeElement !== pick) { setHtml(pick, pickerHtml(list, mounted.got)); pick.value = ''; }
|
||||
const full = panes.length >= MAX;
|
||||
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, why = 'Nothing is read for this thread.', back = false;
|
||||
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) {
|
||||
const how = org.busStopped ? 'stopped' : 'unavailable';
|
||||
line = `${name}: bus is ${how}. This pane cannot read or send until it starts again; the other panes are not affected.`;
|
||||
why = `Nothing is read for this thread while ${name}'s bus is ${how}.`; readable = false; back = true;
|
||||
}
|
||||
else if (i?.threads && !thread) line = `This thread is not in ${name}'s thread list.`;
|
||||
// A held message the last render had no composer for stays in the line between polls, so the
|
||||
// status line neither flickers nor is announced again on every read.
|
||||
const withHeld = () => {
|
||||
const cps = [...p.draft.replace(/\s+/g, ' ').trim()];
|
||||
return `${line ? `${line} ` : ''}Your unsent message is kept until this thread can send: “${cps.length > 200 ? `${cps.slice(0, 200).join('')}…` : cps.join('')}”`;
|
||||
};
|
||||
const shown = readable && p.held && p.draft ? withHeld() : line;
|
||||
setText(e.line, shown); e.line.hidden = !shown;
|
||||
if (!readable) {
|
||||
p.held = false;
|
||||
// An unsent message outlives the chat view: it is held on the pane and put back when the
|
||||
// thread reads again.
|
||||
const typed = p.chat && e.body.querySelector('form textarea')?.value;
|
||||
if (typed?.trim()) p.draft = typed;
|
||||
p.chat?.dispose(); p.chat = null;
|
||||
const kept = !p.draft ? '' : back ? ' Your unsent message is kept and comes back here when it reads again.' : ' Your unsent message is kept while this pane stays open.';
|
||||
setHtml(e.body, `<p class="ws-line">${esc(why)}${kept}</p>`);
|
||||
return;
|
||||
}
|
||||
if (!p.chat) { p.chat = window.MosaicChat.pane(p.org); p.first = true; }
|
||||
const first = p.first, chat = p.chat, seq = ++p.seq; p.first = false;
|
||||
void chat.render({ path: `/chat/threads/${p.id}`, container: e.body, canSend: !!list && i?.chatSend === true, navigated: first }).then(() => {
|
||||
// Only the latest render of this pane decides: an earlier one that a newer render cut short
|
||||
// has no composer yet and must not claim the thread cannot send.
|
||||
if (seq !== p.seq || p.chat !== chat || !p.el.isConnected) return;
|
||||
const field = p.draft ? e.body.querySelector('form textarea') : null;
|
||||
if (p.draft && !field) {
|
||||
// The thread reads but offers no composer: the held message stays visible here, never silently held.
|
||||
p.held = true; const text = withHeld(); setText(e.line, text); e.line.hidden = false;
|
||||
return;
|
||||
}
|
||||
// Put the held message back only into an empty composer; text typed meanwhile is never overwritten.
|
||||
if (field && !field.value) { field.value = p.draft; field.dispatchEvent(new Event('input', { bubbles: true })); }
|
||||
if (field && field.value === p.draft) p.draft = '';
|
||||
if (p.held && (field || !p.draft)) { p.held = false; setText(e.line, line); e.line.hidden = !line; }
|
||||
}, () => {});
|
||||
}
|
||||
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 => `<button type="button" data-assist-show="${esc(p.key)}" aria-pressed="${show === p.key}" aria-controls="assist-p${p.n}">${esc(p.title || 'Reading…')}</button>`).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, draft: '', held: false, seq: 0 };
|
||||
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 => `<option value="${esc(p.id)}">${esc(p.name || p.id)}</option>`).join('');
|
||||
instance.innerHTML = (agents || []).filter(a => a && typeof a.instance === 'string').map(a => `<option value="${esc(a.instance)}">${esc(a.name || a.instance)}</option>`).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.draft.trim() || !!p.chat?.hasDraft());
|
||||
// A write that changed an organization reads it again.
|
||||
document.addEventListener('mosaic-org-changed', e => { if (mounted && mounted.panes.some(p => p.org === e.detail?.org)) { infos.delete(e.detail.org); void draw(); } });
|
||||
|
||||
window.MosaicAssistant = Object.freeze({ mount, unmount, refresh, hasDraft });
|
||||
})();
|
||||
@@ -197,7 +197,7 @@
|
||||
if (parts[0] === 'board' && /^#\/orgs\//.test(location.hash)) { box.innerHTML = '<span aria-current="page">Control board</span>'; return; }
|
||||
if (!parts.length || parts[0] === 'board' || parts[0] === 'dashboard') { box.innerHTML = '<span aria-current="page">Dashboard</span>'; return; }
|
||||
if (parts[0] === 'organization') { const h = $('bus-view').querySelector('[data-org-dashboard] h1')?.textContent?.trim(); box.innerHTML = `<a href="#/dashboard">Dashboard</a>${h ? `<span aria-hidden="true">/</span><span aria-current="page">${escape(h)}</span>` : ''}`; 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 = '<a href="#/settings">Settings</a><span aria-hidden="true">/</span><span aria-current="page">Business</span>'; 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');
|
||||
|
||||
@@ -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) ?? `<svg class="i" aria-hidden="true"><use href="/icons.svg#i-${id}"/></svg>`;
|
||||
const n = (count, what) => count > 0 ? `<span class="ws-n hot" aria-label="${count} ${what}">${count}</span>` : '';
|
||||
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', '', '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)}` }));
|
||||
|
||||
@@ -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 <code>.
|
||||
const prose = value => txt(value).replace(/`([^`\n]+)`/g, '<code>$1</code>');
|
||||
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) ? `<a href="${type === 'projects' ? projectPath(id) : threadPath(id)}">${txt(label)}</a>` : txt(label);
|
||||
const heading = text => `<h1 tabindex="-1">${txt(text)}</h1>`;
|
||||
// A pane's title is its own header; the page keeps its one h1.
|
||||
const heading = text => pane ? `<p class="chat-pane-state"><strong>${txt(text)}</strong></p>` : `<h1 tabindex="-1">${txt(text)}</h1>`;
|
||||
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 `<div class="chat-library"><p role="status">${parts.join(' · ')}</p>${list}</div>`;
|
||||
}
|
||||
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 => `<div class="request-mount" data-request="${esc(m.request)}" data-origin-message="${esc(m.id)}"></div>`;
|
||||
const messages = s.rows.map((m, i) => `<li class="${m.from_role === 'human' ? 'chat-message-human' : 'chat-message-agent'}" data-message="${esc(m.id)}"><div class="chat-message-meta"><span class="chat-avatar" aria-hidden="true">${txt(String(m.from_role || '?').slice(0, 1).toUpperCase())}</span><strong>${txt(m.from_role === 'human' ? 'You' : m.from_role)}</strong> ${stamp(m.at)}</div>${messageBody(m)}${!redesigned && withRequest.has(m.id) ? mount(m) : ''}</li>${redesigned ? (mountsAfter.get(i) || []).map(origin => `<li class="project-request-item">${mount(origin)}</li>`).join('') : ''}`).join('');
|
||||
const frame = redesigned ? window.MosaicProjects.header(project, 'conversations', current.threads, t.name, true) : heading(t.name);
|
||||
const details = redesigned ? `<dialog class="chat-details project-dialog" id="project-conversation-dialog" aria-labelledby="project-conversation-title"><h2 id="project-conversation-title" tabindex="-1">Conversation details</h2><button class="btn" type="button" data-dialog-close>Close</button>${sessionControls(t)}${sharing(t)}<p>Reading never wakes the assistant. An accepted message is not an answer.</p>${threadWrites() ? `<button class="btn" type="button" id="chat-resume"${disabledSession(s.pending || current.channelDisabled)}>Resume assistant</button>` : ''}</dialog>` : `<details class="chat-details"><summary id="chat-details-toggle">Conversation details</summary>${sessionControls(t)}${sharing(t)}</details><p class="muted">Reading never wakes the assistant. An accepted message is not an answer.</p>${threadWrites() ? `<button class="btn" type="button" id="chat-resume"${disabledSession(s.pending || current.channelDisabled)}>Resume assistant</button>` : ''}`;
|
||||
return `<section class="chat-conversation${redesigned ? ' project-chat' : ''}" data-project="${esc(t.project)}" data-thread="${esc(t.id)}" data-session="${esc(current.session)}"><p>${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}</p>${frame}${libraryLine(libraryOf(current, t))}${details}${redesigned ? '<p class="small muted" data-conversation-request-status></p>' : ''}<ol class="chat-log" tabindex="0" aria-label="Thread conversation">${messages || (current.readFailed ? '<li class="muted">Messages unavailable.</li>' : '<li class="muted">No messages yet.</li>')}</ol><div class="chat-paging">${current.readFailed ? '<button type="button" class="btn" data-chat-retry>Read again</button>' : s.more ? '<p>Showing earlier history. Read forward to reach the current conversation.</p><button class="btn" type="button" id="chat-next">Read next messages</button>' : '<p class="muted">Caught up to the last read.</p>'}<button class="btn" type="button" id="chat-new"${s.follow ? ' hidden' : ''}>New messages — go to end</button></div>${threadWrites() ? `<form id="chat-send-form"><label for="chat-body">Message ${txt(t.instance)}</label><textarea id="chat-body" rows="4" placeholder="${redesigned ? `Message ${esc(t.instance)}` : 'Write a message…'}"${disabledSession(s.pending)}>${esc(s.draft)}</textarea><p class="chat-audience">To: ${redesigned ? `<span class="room-chip" aria-label="Recipient ${esc(t.instance)}">${txt(t.instance)}</span>` : `${txt(t.instance)} · Private conversation`}</p>${redesigned ? '<p class="project-composer-hint">Enter adds a new line.</p>' : ''}<button class="btn primary" type="submit"${disabledSession(s.pending || current.channelDisabled)}>Send</button></form>` : ''}<p id="chat-receipt" role="status">${txt(receipt)}</p>${redesigned ? '' : '<section class="chat-files"><h2>Files</h2><div data-chat-files>Files unavailable.</div></section><div data-chat-summary></div>'}</section>`;
|
||||
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() ? `<button class="btn" type="button" id="chat-resume"${disabledSession(s.pending || current.channelDisabled)}>Resume assistant</button>` : '';
|
||||
const details = pane ? `<p class="muted small">Reading never wakes the assistant. An accepted message is not an answer.</p>` : redesigned ? `<dialog class="chat-details project-dialog" id="project-conversation-dialog" aria-labelledby="project-conversation-title"><h2 id="project-conversation-title" tabindex="-1">Conversation details</h2><button class="btn" type="button" data-dialog-close>Close</button>${sessionControls(t)}${sharing(t)}<p>Reading never wakes the assistant. An accepted message is not an answer.</p>${threadWrites() ? `<button class="btn" type="button" id="chat-resume"${disabledSession(s.pending || current.channelDisabled)}>Resume assistant</button>` : ''}</dialog>` : `<details class="chat-details"><summary id="chat-details-toggle">Conversation details</summary>${sessionControls(t)}${sharing(t)}</details><p class="muted">Reading never wakes the assistant. An accepted message is not an answer.</p>${threadWrites() ? `<button class="btn" type="button" id="chat-resume"${disabledSession(s.pending || current.channelDisabled)}>Resume assistant</button>` : ''}`;
|
||||
return `<section class="chat-conversation${redesigned ? ' project-chat' : ''}" data-project="${esc(t.project)}" data-thread="${esc(t.id)}" data-session="${esc(current.session)}">${pane ? '' : `<p>${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}</p>`}${frame}${libraryLine(libraryOf(current, t))}${details}${redesigned ? '<p class="small muted" data-conversation-request-status></p>' : ''}<ol class="chat-log" tabindex="0" aria-label="Thread conversation">${messages || (current.readFailed ? '<li class="muted">Messages unavailable.</li>' : '<li class="muted">No messages yet.</li>')}</ol><div class="chat-paging">${current.readFailed ? '<button type="button" class="btn" data-chat-retry>Read again</button>' : s.more ? '<p>Showing earlier history. Read forward to reach the current conversation.</p><button class="btn" type="button" id="chat-next">Read next messages</button>' : '<p class="muted">Caught up to the last read.</p>'}<button class="btn" type="button" id="chat-new"${s.follow ? ' hidden' : ''}>New messages — go to end</button></div>${threadWrites() ? `<form id="chat-send-form"><label for="chat-body">Message ${txt(t.instance)}</label><textarea id="chat-body" rows="4" placeholder="${redesigned ? `Message ${esc(t.instance)}` : 'Write a message…'}"${disabledSession(s.pending)}>${esc(s.draft)}</textarea><p class="chat-audience">To: ${redesigned ? `<span class="room-chip" aria-label="Recipient ${esc(t.instance)}">${txt(t.instance)}</span>` : `${txt(t.instance)} · Private conversation`}</p>${redesigned ? '<p class="project-composer-hint">Enter adds a new line.</p>' : ''}<button class="btn primary" type="submit"${disabledSession(s.pending || current.channelDisabled)}>Send</button></form>` : ''}<p id="chat-receipt" role="status">${txt(receipt)}</p>${pane ? resume : ''}${redesigned || pane ? '' : '<section class="chat-files"><h2>Files</h2><div data-chat-files>Files unavailable.</div></section><div data-chat-summary></div>'}</section>`;
|
||||
}
|
||||
// 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 = '<div class="chat-area"><p><a href="#/projects">Projects</a></p>';
|
||||
const redesigned = !pane && (!legacyRoute() || c.path?.startsWith('/projects')) && !!window.MosaicProjects && !c.user && !c.tester;
|
||||
let html = pane ? '<div class="chat-area">' : '<div class="chat-area"><p><a href="#/projects">Projects</a></p>';
|
||||
if (c.orgArchived) html += `<p role="status">${c.orgArchived === 'room-not-open' ? 'This room is not open.' : 'This organization is archived.'} Sending is unavailable; your draft is kept.</p>`;
|
||||
if (c.error && !(c.kind === 'room' && c.error === ROOM_LINES['room-not-open'] && redesigned)) html += `<p class="chat-error banner err" role="alert">${txt(c.error)}</p>`;
|
||||
if (c.archiveUnknown && !c.loading) html += '<p role="status" data-archive-notice>Archive status unavailable. Some changes are unavailable; sending may be refused; refresh to check.</p>';
|
||||
@@ -635,8 +657,10 @@
|
||||
} else {
|
||||
html += heading('Projects') + '<p>Projects and their conversations.</p><div class="chat-projects">' + c.projects.map(p => `<section class="panel"><h2>${link('projects', p.id, p.name)}</h2>${p.repo ? `<p class="muted">Repository: ${txt(p.repo)}</p>` : ''}${threads(c.threads.filter(t => t.project === p.id && !t.shared))}</section>`).join('') + (c.projects.length ? '' : c.readFailed ? '<p>Projects unavailable.</p>' : '<p>No projects yet.</p>') + '</div><h2>Shared with me</h2>' + threads(c.threads.filter(t => t.shared)) + creation('project', 'projects');
|
||||
}
|
||||
if (!c.canSend) html += window.MosaicOrgBusStopped ? '<p class="muted">The bus is stopped. Use Start the bus on Settings → Organizations to send messages.</p>' : '<p class="muted">Creating and sending require signed-in send mode. This Console only reads.</p>';
|
||||
if (!c.canSend && pane) html += '<p class="muted">Sending is unavailable in this organization. This thread only reads.</p>';
|
||||
else if (!c.canSend) html += window.MosaicOrgBusStopped ? '<p class="muted">The bus is stopped. Use Start the bus on Settings → Organizations to send messages.</p>' : '<p class="muted">Creating and sending require signed-in send mode. This Console only reads.</p>';
|
||||
html += '</div>';
|
||||
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) {
|
||||
@@ -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 });
|
||||
})();
|
||||
@@ -21,7 +21,7 @@
|
||||
.then(r => (r.type === 'opaqueredirect' ? null : r.ok ? r.json() : null));
|
||||
const ready = readConfig().then(c => { config = c; boot = typeof c?.boot === 'string' ? c.boot : null; return c; }).catch(() => null);
|
||||
|
||||
const dirty = () => !!(window.MosaicChat?.hasDraft?.() || window.MosaicOrgCreate?.hasDraft?.()
|
||||
const dirty = () => !!(window.MosaicChat?.hasDraft?.() || window.MosaicAssistant?.hasDraft?.() || window.MosaicOrgCreate?.hasDraft?.()
|
||||
|| document.querySelector('.settings-dot')
|
||||
|| [...document.querySelectorAll('#reply, #conv-reply, #doc-source, #send-body')].some(el => !!el.value?.trim()));
|
||||
|
||||
|
||||
@@ -25,7 +25,8 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date(
|
||||
// organization's items); `#/orgs/<id>/inbox` is that list, filtered. SP5: `#/agents` is every
|
||||
// organization's agents; `#/orgs/<id>/agents` is that table for one organization. SP4: `#/tasks` is
|
||||
// one task board for every organization; `#/orgs/<id>/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)}`;
|
||||
@@ -117,7 +118,7 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date(
|
||||
return read;
|
||||
};
|
||||
let previous = location.hash, skipGuard = false;
|
||||
const dirty = () => window.MosaicChat?.hasDraft?.() || window.MosaicOrgCreate?.hasDraft?.() || [...document.querySelectorAll('#reply, #conv-reply, #doc-source')].some(el => !!el.value?.trim());
|
||||
const dirty = () => window.MosaicChat?.hasDraft?.() || window.MosaicAssistant?.hasDraft?.() || window.MosaicOrgCreate?.hasDraft?.() || [...document.querySelectorAll('#reply, #conv-reply, #doc-source')].some(el => !!el.value?.trim());
|
||||
// A switch scopes later reads to the new organization; listeners drop what they held for the old one.
|
||||
function rescope(seg) {
|
||||
const from = current;
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
<link rel="stylesheet" href="/project-archive.css"><link rel="stylesheet" href="/projects.css">
|
||||
<link rel="stylesheet" href="/processing.css"><script src="/processing.js" defer></script>
|
||||
<link rel="stylesheet" href="/org-create.css"><script src="/org-create.js" defer></script>
|
||||
<link rel="stylesheet" href="/onboarding.css"><script src="/onboarding.js" defer></script><link rel="stylesheet" href="/panes.css">
|
||||
<script src="/workspace-data.js" defer></script><script src="/workspace.js" defer></script><script src="/tasks-board.js" defer></script><script src="/console-restart.js" defer></script><script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><link rel="stylesheet" href="/autonomy.css"><script src="/autonomy.js" defer></script><link rel="stylesheet" href="/packs.css"><script src="/packs.js" defer></script><script src="/workflows.js" defer></script><script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/projects.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/panes.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
|
||||
<link rel="stylesheet" href="/onboarding.css"><script src="/onboarding.js" defer></script><link rel="stylesheet" href="/assistant.css">
|
||||
<script src="/workspace-data.js" defer></script><script src="/workspace.js" defer></script><script src="/tasks-board.js" defer></script><script src="/console-restart.js" defer></script><script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><link rel="stylesheet" href="/autonomy.css"><script src="/autonomy.js" defer></script><link rel="stylesheet" href="/packs.css"><script src="/packs.js" defer></script><script src="/workflows.js" defer></script><script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/projects.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/assistant.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
|
||||
<body>
|
||||
<div hidden aria-hidden="true"><select id="palette"></select><select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">dark</option></select></div>
|
||||
<a class="skip" href="#main">Skip to content</a>
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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/<page>/side-by-side?a=<org>/<route>&b=<org>/<route>&w=<percent>&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 => `<svg class="i sm" viewBox="0 0 24 24" aria-hidden="true" focusable="false">${d}</svg>`;
|
||||
const FULL = svg('<rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M12 4.5v15"/>');
|
||||
const CLOSE = svg('<path d="M6 6l12 12M18 6 6 18"/>');
|
||||
const PLUS = svg('<path d="M12 5v14M5 12h14"/>');
|
||||
|
||||
// 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;
|
||||
}
|
||||
// "<org>/<route>" 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 `<section class="pane" id="pane-${key}" data-pane="${key}" aria-label="${name[0].toUpperCase()}${name.slice(1)} pane">
|
||||
<div class="pane-h ws-o" data-pane-head>
|
||||
<span data-pane-av></span><span class="ttl" data-pane-ttl></span><span data-pane-state></span>
|
||||
<label class="ws-sr" for="pane-${key}-pick">Page in the ${name} pane</label>
|
||||
<select id="pane-${key}-pick" class="pane-pick" data-pane-pick></select>
|
||||
<span class="tools"><a class="pane-tool" data-pane-full href="#" title="Open in full view"><span class="ws-sr" data-pane-full-label>Open in full view</span>${FULL}</a><button type="button" class="pane-tool" data-pane-close title="Close pane" aria-label="Close the ${name} pane">${CLOSE}</button></span>
|
||||
</div>
|
||||
<p class="pane-line" data-pane-line role="status"></p>
|
||||
<div class="pane-body" data-pane-body tabindex="0" aria-label="${name[0].toUpperCase()}${name.slice(1)} pane content"></div>
|
||||
</section>`;
|
||||
}
|
||||
function frame() {
|
||||
return `<section class="sbs" data-side-by-side aria-labelledby="sbs-title">
|
||||
<div class="panebar"><h1 id="sbs-title" tabindex="-1">Side by side</h1><span class="note" data-sbs-note></span>
|
||||
<span class="ws-seg pane-tabs" role="group" aria-label="Shown pane" data-sbs-tabs></span>
|
||||
<button type="button" class="ws-btn hide-m" data-pane-add>${PLUS}Add pane</button></div>
|
||||
<div class="panes" data-sbs-panes>${paneShell('a')}<div class="pane-div" role="separator" tabindex="0" aria-orientation="vertical" aria-label="Resize panes" aria-controls="pane-a pane-b" aria-valuemin="${MIN}" aria-valuemax="${MAX}" data-sbs-divider></div>${paneShell('b')}</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
// 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 `<optgroup label="${esc(name)} · invalid"><option disabled value="">Organization invalid</option></optgroup>`;
|
||||
const rows = await projectsOf(org.id);
|
||||
const opt = (route, text) => `<option value="${esc(`${org.id}${route}`)}">${esc(text)}</option>`;
|
||||
const projects = rows === null ? '<option disabled value="">Projects unavailable</option>' : rows.filter(r => !r.archived && PROJECT.test(`/projects/${r.id}`)).map(r => opt(`/projects/${r.id}`, `Project: ${r.name || r.id}`)).join('');
|
||||
return `<optgroup label="${esc(name)}${org.busHealthy ? '' : ' · bus unavailable'}">${opt('/organization', 'Dashboard')}${opt('/agents', 'Agents')}${projects}</optgroup>`;
|
||||
}));
|
||||
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 = `<option value="${esc(value)}">${esc(p.title || value)}</option>${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') : '<span class="ws-av md" aria-hidden="true">?</span>');
|
||||
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 = '<p class="ws-line">Nothing is read for an organization that is not on your list.</p>'; }
|
||||
else if (org.invalid) { line = `${name}: organization invalid${org.reason ? ` (${org.reason})` : ''}.`; html = '<p class="ws-line">Nothing is read from an invalid organization. Fix its file under Settings, Organizations.</p>'; }
|
||||
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(() => '<p class="ws-line">Dashboard unavailable.</p>');
|
||||
else if (html === null && what?.kind === 'agents') html = await ws().agentGrid('', p.org).catch(() => '<p class="ws-line">Agents unavailable.</p>');
|
||||
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)}<small>${esc(name)}</small>`);
|
||||
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 ? '<p class="ws-line">Projects unavailable. The organization did not answer the project read.</p>' : '<p class="ws-line">This project is not in the organization\'s project list.</p>');
|
||||
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 = `<div class="chat-area pane-project">${head}${conv ?? '<div data-project-content>Reading…</div>'}</div>`;
|
||||
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 '<p class="ws-line">Conversations unavailable.</p>';
|
||||
const own = threads.filter(t => t && !t.shared && t.project === project.id && typeof t.id === 'string');
|
||||
const rows = own.map(t => `<a class="project-conversation-row" href="#/projects/threads/${enc(t.id)}" data-focus-key="${esc(`conv:${t.id}`)}"><span class="project-conversation-icon" aria-hidden="true">◌</span><span><strong>${esc(t.name || t.id)}</strong><small>With ${esc(t.instance || 'no agent recorded')}</small></span><span aria-hidden="true">›</span></a>`).join('');
|
||||
return `<div class="project-section-title"><h2>Conversations</h2></div><div data-project-conversations>${rows || '<p>No conversations yet.</p>'}</div><p class="ws-line">A conversation opens in its full view.</p>`;
|
||||
}
|
||||
|
||||
// ---------- 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 `<button type="button" data-pane-show="${p.key}" aria-pressed="${show === p.key}" aria-controls="pane-${p.key}">${org ? ws().av(org, 'xs') : ''}<span>${esc(p.title || 'Reading…')}</span></button>`;
|
||||
}).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 });
|
||||
})();
|
||||
@@ -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']],
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
// SP6 (#1873): the Assistant is one system page. Up to three chat threads from any organization
|
||||
// open next to each other, each the chat thread view pinned to its own organization.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createDevOwner } from '../src/dev-owner.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
|
||||
const P = { alpha: '10000000-0000-4000-8000-00000000000a', beta: '10000000-0000-4000-8000-00000000000b', home: '10000000-0000-4000-8000-00000000000c' };
|
||||
const T = {
|
||||
a1: '20000000-0000-4000-8000-0000000000a1', a2: '20000000-0000-4000-8000-0000000000a2',
|
||||
b1: '20000000-0000-4000-8000-0000000000b1', h1: '20000000-0000-4000-8000-0000000000c1',
|
||||
};
|
||||
const NEW = '20000000-0000-4000-8000-0000000000b9';
|
||||
|
||||
// Three organizations. `state.down` names the ones whose bus is stopped; `state.sent` and
|
||||
// `state.created` record every chat write with the organization whose transport received it.
|
||||
function orgs(state) {
|
||||
const ORGS = [
|
||||
{ id: 'alpha', label: 'Alpha', theme: 'rosewood' },
|
||||
{ id: 'beta', label: 'Beta', theme: 'cobalt' },
|
||||
{ id: 'home', label: 'Home', theme: 'grove' },
|
||||
];
|
||||
const PROJECTS = { alpha: [{ id: P.alpha, name: 'Spring launch' }], beta: [{ id: P.beta, name: 'API gateway' }], home: [{ id: P.home, name: 'Garden' }] };
|
||||
const THREADS = {
|
||||
alpha: [{ id: T.a1, project: P.alpha, name: 'Launch email', instance: 'assistant' }, { id: T.a2, project: P.alpha, name: 'Kickoff notes', instance: 'assistant' }],
|
||||
beta: [{ id: T.b1, project: P.beta, name: 'Gateway auth', instance: 'assistant' }],
|
||||
home: [{ id: T.h1, project: P.home, name: 'Seed order', instance: 'assistant' }],
|
||||
};
|
||||
const MESSAGES = { [T.a1]: 'Alpha draft is ready.', [T.a2]: 'Kickoff agenda.', [T.b1]: 'Beta tokens rotate nightly.', [T.h1]: 'Seeds arrive Monday.' };
|
||||
const down = () => { throw Object.assign(new Error('bus down'), { code: 'bus-unavailable' }); };
|
||||
return {
|
||||
list: async () => ORGS.filter(o => !state.gone?.has(o.id)).map(o => ({ ...o, busHealthy: !state.down.has(o.id), ...(state.down.has(o.id) ? { busStopped: true } : {}) })),
|
||||
resolve: async id => {
|
||||
const org = ORGS.find(o => o.id === id); if (!org || state.gone?.has(id)) return null;
|
||||
if (state.down.has(id)) return { busHealthy: false, reads: null, bus: {}, chat: { projects: down, threads: down, sessions: down, shares: down, messages: down } };
|
||||
const threads = () => [...THREADS[id], ...state.created.filter(c => c.org === id).map(c => ({ id: c.id, project: c.project, name: c.name, instance: c.instance }))];
|
||||
return {
|
||||
busHealthy: true,
|
||||
send: async () => ({}),
|
||||
reads: { business: async () => ({ business: { id } }), settings: async () => ({}), busStatus: async () => ({ live: true, down: false }), queue: async () => ({ rows: [] }) },
|
||||
bus: {
|
||||
inbox: async () => [], agents: async () => [], tasks: async () => [], 'thread.claims': async () => [], messages: async () => ({ messages: [], unread: 0 }),
|
||||
instances: async () => ({ business: id, revision: 'a'.repeat(64), definitions: {}, instances: { assistant: { definition: 'assistant', vars: {} } } }),
|
||||
},
|
||||
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' })),
|
||||
...(state.replies || []).filter(r => r.org === id && r.thread === thread).map((r, n) => ({ id: `50000000-0000-4000-8000-${String(n + 1).padStart(12, '0')}`, from_role: 'assistant', body: r.body, at: '2026-11-15T10:02:00.000Z' })),
|
||||
],
|
||||
send: async ({ thread, body }) => {
|
||||
const sent = { org: id, thread, body, id: `40000000-0000-4000-8000-${String(state.sent.length + 1).padStart(12, '0')}` };
|
||||
state.sent.push(sent);
|
||||
return { id: sent.id };
|
||||
},
|
||||
createThread: async ({ project, name, instance }) => {
|
||||
state.created.push({ org: id, project, name, instance, id: NEW });
|
||||
return { id: NEW, instance };
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function open(t, { width = 1440, height = 900, hash, state = { down: new Set(['home']), sent: [], created: [] } } = {}) {
|
||||
const organizations = orgs(state);
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, send: async () => ({}), board: 'http://127.0.0.1:9', log: () => {} });
|
||||
t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
await b.viewport(width, height);
|
||||
const base = `http://127.0.0.1:${server.address().port}/`;
|
||||
await b.navigate(base + hash);
|
||||
const wait = (expression, tries = 200) => b.evaluate(`(async()=>{for(let i=0;i<${tries};i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
|
||||
return { b, base, wait, state };
|
||||
}
|
||||
|
||||
const PANE = sel => `document.querySelector('[data-assist-pane="${sel}"]')`;
|
||||
const panes = `[...document.querySelectorAll('[data-assist-pane]')].map(p => p.dataset.assistPane)`;
|
||||
const shown = `[...document.querySelectorAll('[data-assist-pane]')].filter(p => p.getClientRects().length).map(p => p.dataset.assistPane)`;
|
||||
const query = `new URLSearchParams(location.hash.split('?')[1] || '').get('open')`;
|
||||
const ready = sel => `!!${PANE(sel)}?.querySelector('.chat-log li') && !!${PANE(sel)}?.querySelector('[data-ws-orgchip]')`;
|
||||
const SENT = '/Accepted message|Message shown in this conversation/';
|
||||
const pick = (b, value) => b.evaluate(`(() => { const s = document.querySelector('[data-assist-open]'); s.value = ${JSON.stringify(value)}; if (s.value !== ${JSON.stringify(value)}) throw Error('no option ' + ${JSON.stringify(value)}); s.dispatchEvent(new Event('change', { bubbles: true })); })()`);
|
||||
|
||||
test('the sidebar Assistant opens one system page, and two threads from two organizations open side by side with their chips', async t => {
|
||||
const { b, wait } = await open(t, { hash: '#/orgs/alpha/tasks' });
|
||||
await wait(`!!document.querySelector('#sections a[href="#/assistant"]')`);
|
||||
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('#sections a')].map(a => a.textContent.trim())`).then(v => v.includes('Side by side')), false, 'no Side by side item');
|
||||
await b.evaluate(`document.querySelector('#sections a[href="#/assistant"]').click()`);
|
||||
await wait(`location.hash.startsWith('#/assistant') && document.querySelector('#bus-view h1')?.textContent === 'Assistant'`);
|
||||
assert.equal(await b.evaluate('document.querySelector(\'#sections a[href="#/assistant"]\').getAttribute("aria-current")'), 'page');
|
||||
await wait(`!!document.querySelector('[data-assist-open] optgroup[label^="Beta"] option[value="beta:${T.b1}"]')`);
|
||||
await pick(b, `alpha:${T.a1}`);
|
||||
await pick(b, `beta:${T.b1}`);
|
||||
await wait(`${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)}`);
|
||||
assert.deepEqual(await b.evaluate(panes), [`alpha:${T.a1}`, `beta:${T.b1}`]);
|
||||
assert.deepEqual(await b.evaluate(shown), [`alpha:${T.a1}`, `beta:${T.b1}`], 'both panes show at 1440');
|
||||
const head = sel => b.evaluate(`(() => { const p = ${PANE(sel)}; return { title: p.querySelector('[data-assist-title]').textContent, org: p.querySelector('[data-ws-orgchip]').textContent, project: p.querySelector('[data-ws-projchip]')?.textContent ?? null, close: !!p.querySelector('button[data-assist-close]') }; })()`);
|
||||
assert.deepEqual(await head(`alpha:${T.a1}`), { title: 'Launch email', org: 'Alpha', project: 'Spring launch', close: true });
|
||||
assert.deepEqual(await head(`beta:${T.b1}`), { title: 'Gateway auth', org: 'Beta', project: 'API gateway', close: true });
|
||||
assert.match(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.innerText`), /Alpha draft is ready\./);
|
||||
assert.match(await b.evaluate(`${PANE(`beta:${T.b1}`)}.innerText`), /Beta tokens rotate nightly\./);
|
||||
assert.doesNotMatch(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.innerText`), /Beta tokens/);
|
||||
// One level-1 heading on the page; each pane's own form, field and ids are its own.
|
||||
assert.equal(await b.evaluate(`document.querySelectorAll('#bus-view h1').length`), 1);
|
||||
const ids = await b.evaluate(`[...document.querySelectorAll('#bus-view [id]')].map(e => e.id)`);
|
||||
assert.equal(new Set(ids).size, ids.length, 'no duplicate ids across panes');
|
||||
assert.equal(await b.evaluate(`document.querySelectorAll('#bus-view form textarea').length`), 2);
|
||||
});
|
||||
|
||||
test('the open set and its order live in the address, so a reload or a pasted address restores the same panes', async t => {
|
||||
const hash = `#/assistant?open=beta:${T.b1},alpha:${T.a2}`;
|
||||
const { b, base, wait } = await open(t, { hash });
|
||||
await wait(`${ready(`beta:${T.b1}`)} && ${ready(`alpha:${T.a2}`)}`);
|
||||
assert.deepEqual(await b.evaluate(panes), [`beta:${T.b1}`, `alpha:${T.a2}`]);
|
||||
await pick(b, `alpha:${T.a1}`);
|
||||
await wait(`${ready(`alpha:${T.a1}`)}`);
|
||||
assert.equal(await b.evaluate(query), `beta:${T.b1},alpha:${T.a2},alpha:${T.a1}`);
|
||||
const address = await b.evaluate('location.hash');
|
||||
await b.navigate('about:blank');
|
||||
await b.navigate(base + address);
|
||||
await wait(`${ready(`beta:${T.b1}`)} && ${ready(`alpha:${T.a2}`)} && ${ready(`alpha:${T.a1}`)}`);
|
||||
assert.deepEqual(await b.evaluate(panes), [`beta:${T.b1}`, `alpha:${T.a2}`, `alpha:${T.a1}`]);
|
||||
// A hand-edited address is checked before use: bad selectors, repeats and a fourth pane are dropped.
|
||||
await b.navigate('about:blank');
|
||||
await b.navigate(base + `#/assistant?open=alpha:${T.a1},alpha:${T.a1},nope,../x:${T.b1},beta:not-a-uuid,beta:${T.b1},alpha:${T.a2},home:${T.h1}`);
|
||||
await wait(`document.querySelectorAll('[data-assist-pane]').length === 3`);
|
||||
assert.deepEqual(await b.evaluate(panes), [`alpha:${T.a1}`, `beta:${T.b1}`, `alpha:${T.a2}`]);
|
||||
await wait(`${query} === 'alpha:${T.a1},beta:${T.b1},alpha:${T.a2}'`);
|
||||
// With three open the picker offers no fourth.
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-assist-open]').disabled`), true);
|
||||
});
|
||||
|
||||
test('a send in organization B\'s pane reaches B\'s thread while organization A was opened last', async t => {
|
||||
const state = { down: new Set(['home']), sent: [], created: [] };
|
||||
const { b, wait } = await open(t, { hash: '#/orgs/beta/tasks', state });
|
||||
await wait(`window.MosaicOrg?.id === 'beta'`);
|
||||
await b.evaluate(`location.hash = '#/orgs/alpha/tasks'`);
|
||||
await wait(`window.MosaicOrg?.id === 'alpha'`);
|
||||
await b.evaluate(`location.hash = '#/assistant?open=beta:${T.b1},alpha:${T.a1}'`);
|
||||
await wait(`${ready(`beta:${T.b1}`)} && ${ready(`alpha:${T.a1}`)}`);
|
||||
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'alpha', 'alpha is the organization opened last');
|
||||
const field = `${PANE(`beta:${T.b1}`)}.querySelector('form textarea')`;
|
||||
await wait(`!${field}.disabled`);
|
||||
await b.evaluate(`(() => { const el = ${field}; el.value = 'rotate the beta key'; el.dispatchEvent(new Event('input', { bubbles: true })); el.form.requestSubmit(); })()`);
|
||||
await wait(`${SENT}.test(${PANE(`beta:${T.b1}`)}.innerText)`);
|
||||
assert.deepEqual(state.sent.map(({ org, thread, body }) => ({ org, thread, body })), [{ org: 'beta', thread: T.b1, body: 'rotate the beta key' }]);
|
||||
assert.doesNotMatch(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.innerText`), /Accepted message|Message shown in this conversation/, 'the other pane shows no receipt');
|
||||
assert.equal(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.querySelector('form textarea').value`), '', 'the other pane keeps its own draft');
|
||||
// A send in A's pane reaches A.
|
||||
const other = `${PANE(`alpha:${T.a1}`)}.querySelector('form textarea')`;
|
||||
await b.evaluate(`(() => { const el = ${other}; el.value = 'alpha copy'; el.dispatchEvent(new Event('input', { bubbles: true })); el.form.requestSubmit(); })()`);
|
||||
await wait(`${SENT}.test(${PANE(`alpha:${T.a1}`)}.innerText)`);
|
||||
assert.deepEqual(state.sent.map(({ org, thread }) => `${org}:${thread}`), [`beta:${T.b1}`, `alpha:${T.a1}`]);
|
||||
// Live updates: a refresh shows the sent message in its own pane only.
|
||||
await b.evaluate('window.MosaicAssistant.refresh()');
|
||||
await wait(`/rotate the beta key/.test(${PANE(`beta:${T.b1}`)}.querySelector('.chat-log').innerText)`);
|
||||
assert.doesNotMatch(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.querySelector('.chat-log').innerText`), /rotate the beta key/);
|
||||
});
|
||||
|
||||
test('closing a pane updates the address and leaves the others', async t => {
|
||||
const { b, wait } = await open(t, { hash: `#/assistant?open=alpha:${T.a1},beta:${T.b1}` });
|
||||
await wait(`${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)}`);
|
||||
await b.evaluate(`${PANE(`alpha:${T.a1}`)}.querySelector('button[data-assist-close]').click()`);
|
||||
await wait(`document.querySelectorAll('[data-assist-pane]').length === 1`);
|
||||
assert.equal(await b.evaluate(query), `beta:${T.b1}`);
|
||||
assert.match(await b.evaluate(`${PANE(`beta:${T.b1}`)}.innerText`), /Beta tokens rotate nightly\./);
|
||||
await b.evaluate(`${PANE(`beta:${T.b1}`)}.querySelector('button[data-assist-close]').click()`);
|
||||
await wait(`document.querySelectorAll('[data-assist-pane]').length === 0`);
|
||||
assert.equal(await b.evaluate(`location.hash`), '#/assistant');
|
||||
assert.match(await b.evaluate(`document.getElementById('bus-view').innerText`), /No thread is open/);
|
||||
});
|
||||
|
||||
test('a stopped organization is named at the top, its pane says so, and the other panes keep working', async t => {
|
||||
const state = { down: new Set(['home']), sent: [], created: [] };
|
||||
const { b, wait } = await open(t, { hash: `#/assistant?open=alpha:${T.a1},home:${T.h1},beta:${T.b1}`, state });
|
||||
await wait(`!!document.querySelector('#bus-view [data-ws-unread]') && ${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)}`);
|
||||
assert.match(await b.evaluate(`document.querySelector('#bus-view [data-ws-unread]').textContent`), /Home/);
|
||||
assert.doesNotMatch(await b.evaluate(`document.querySelector('#bus-view [data-ws-unread]').textContent`), /Alpha|Beta/);
|
||||
await wait(`/bus is stopped/i.test(${PANE(`home:${T.h1}`)}?.querySelector('[data-assist-line]')?.textContent || '')`);
|
||||
assert.equal(await b.evaluate(`${PANE(`home:${T.h1}`)}.querySelector('[data-ws-orgchip]').textContent`), 'Home');
|
||||
assert.equal(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.querySelector('form textarea').disabled`), false);
|
||||
// Beta's bus stops while its pane is open, with an unsent message in it: that pane says so and
|
||||
// only so, and holds the message; Alpha's still sends.
|
||||
const betaField = `${PANE(`beta:${T.b1}`)}.querySelector('form textarea')`;
|
||||
await wait(`!!${betaField} && !${betaField}.disabled`);
|
||||
await b.evaluate(`(() => { const el = ${betaField}; el.value = 'beta draft'; el.dispatchEvent(new Event('input', { bubbles: true })); })()`);
|
||||
state.down.add('beta');
|
||||
await b.evaluate('window.MosaicAssistant.refresh()');
|
||||
await wait(`/bus is stopped/i.test(${PANE(`beta:${T.b1}`)}.querySelector('[data-assist-line]')?.textContent || '') && /Beta/.test(document.querySelector('#bus-view [data-ws-unread]')?.textContent || '')`);
|
||||
await wait(`!${betaField}`);
|
||||
const betaBody = await b.evaluate(`${PANE(`beta:${T.b1}`)}.querySelector('.assist-body').innerText`);
|
||||
assert.match(betaBody, /Nothing is read for this thread while Beta's bus is stopped\. Your unsent message is kept/);
|
||||
assert.doesNotMatch(betaBody, /Refresh to try again|Read again|Conversation unavailable/, 'no line that says a refresh would help');
|
||||
assert.equal(await b.evaluate('window.MosaicAssistant.hasDraft()'), true, 'the held message counts as a draft');
|
||||
const field = `${PANE(`alpha:${T.a1}`)}.querySelector('form textarea')`;
|
||||
assert.equal(await b.evaluate(`${field}.disabled`), false);
|
||||
await b.evaluate(`(() => { const el = ${field}; el.value = 'still here'; el.dispatchEvent(new Event('input', { bubbles: true })); el.form.requestSubmit(); })()`);
|
||||
await wait(`${SENT}.test(${PANE(`alpha:${T.a1}`)}.innerText)`);
|
||||
assert.deepEqual(state.sent.map(({ org, body }) => `${org}:${body}`), ['alpha:still here']);
|
||||
// The picker names the stopped organizations and offers none of their threads.
|
||||
assert.match(await b.evaluate(`[...document.querySelectorAll('[data-assist-open] optgroup')].map(g => g.label).join('|')`), /Home · bus stopped/);
|
||||
// Beta's bus starts again: its pane reads again and the held message is back in its composer.
|
||||
state.down.delete('beta');
|
||||
await b.evaluate('window.MosaicAssistant.refresh()');
|
||||
await wait(`${ready(`beta:${T.b1}`)} && ${betaField}?.value === 'beta draft'`);
|
||||
assert.deepEqual(state.sent.map(({ org, body }) => `${org}:${body}`), ['alpha:still here'], 'the held message is not sent by itself');
|
||||
});
|
||||
|
||||
test('a pane whose organization leaves the list keeps its unsent message and does not promise it comes back', async t => {
|
||||
const state = { down: new Set(['home']), gone: new Set(), sent: [], created: [] };
|
||||
const { b, wait } = await open(t, { hash: `#/assistant?open=alpha:${T.a1},beta:${T.b1}`, state });
|
||||
await wait(`${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)}`);
|
||||
const betaField = `${PANE(`beta:${T.b1}`)}.querySelector('form textarea')`;
|
||||
await wait(`!!${betaField} && !${betaField}.disabled`);
|
||||
await b.evaluate(`(() => { const el = ${betaField}; el.value = 'beta draft'; el.dispatchEvent(new Event('input', { bubbles: true })); })()`);
|
||||
state.gone.add('beta');
|
||||
await b.evaluate('window.MosaicAssistant.refresh()');
|
||||
await wait(`/not on your organization list/.test(${PANE(`beta:${T.b1}`)}.querySelector('[data-assist-line]')?.textContent || '') && !${betaField}`);
|
||||
const body = await b.evaluate(`${PANE(`beta:${T.b1}`)}.querySelector('.assist-body').innerText`);
|
||||
assert.match(body, /Your unsent message is kept while this pane stays open\./);
|
||||
assert.doesNotMatch(body, /comes back/, 'no promise that an organization off the list reads again');
|
||||
assert.equal(await b.evaluate('window.MosaicAssistant.hasDraft()'), true);
|
||||
assert.deepEqual(state.sent, []);
|
||||
});
|
||||
|
||||
test('an open pane shows a new reply on its own, with no refresh', async t => {
|
||||
const state = { down: new Set(['home']), sent: [], created: [], replies: [] };
|
||||
const { b, wait } = await open(t, { hash: `#/assistant?open=alpha:${T.a1},beta:${T.b1}`, state });
|
||||
await wait(`${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)}`);
|
||||
state.replies.push({ org: 'beta', thread: T.b1, body: 'Rotation finished.' });
|
||||
// The page polls every 10 s; wait up to 15 s without calling refresh.
|
||||
await wait(`/Rotation finished\./.test(${PANE(`beta:${T.b1}`)}.querySelector('.chat-log').innerText)`, 500);
|
||||
assert.doesNotMatch(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.querySelector('.chat-log').innerText`), /Rotation finished/);
|
||||
});
|
||||
|
||||
test('the old Side by side address lands on the Assistant page', async t => {
|
||||
const { b, wait } = await open(t, { hash: '#/orgs/alpha/side-by-side?a=alpha/organization&b=beta/organization&w=50&show=a' });
|
||||
await wait(`location.hash === '#/assistant' && document.querySelector('#bus-view h1')?.textContent === 'Assistant'`);
|
||||
assert.equal(await b.evaluate(`!!window.MosaicPanes`), false, 'the Side by side module is gone');
|
||||
await b.evaluate(`location.hash = '#/side-by-side'`);
|
||||
await wait(`location.hash === '#/assistant'`);
|
||||
// The old per-organization Assistant address, the messages page, still opens.
|
||||
await b.evaluate(`location.hash = '#/orgs/alpha/messages'`);
|
||||
await wait(`document.querySelector('#bus-view h1')?.textContent === 'Messages'`);
|
||||
});
|
||||
|
||||
test('at 390 one pane shows at a time with a tab strip to switch', async t => {
|
||||
const { b, base, wait } = await open(t, { width: 390, height: 844, hash: `#/assistant?open=alpha:${T.a1},beta:${T.b1}` });
|
||||
await wait(`${ready(`alpha:${T.a1}`)} && document.querySelectorAll('[data-assist-tabs] [data-assist-show]').length === 2`);
|
||||
assert.deepEqual(await b.evaluate(shown), [`alpha:${T.a1}`]);
|
||||
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-assist-tabs] [data-assist-show]')].map(x => [x.textContent.trim(), x.getAttribute('aria-pressed')])`), [['Launch email', 'true'], ['Gateway auth', 'false']]);
|
||||
await b.evaluate(`document.querySelector('[data-assist-tabs] [data-assist-show="beta:${T.b1}"]').click()`);
|
||||
await wait(`${ready(`beta:${T.b1}`)}`);
|
||||
assert.deepEqual(await b.evaluate(shown), [`beta:${T.b1}`]);
|
||||
assert.equal(await b.evaluate(`new URLSearchParams(location.hash.split('?')[1]).get('show')`), `beta:${T.b1}`);
|
||||
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), true, 'no horizontal page scroll'); // A pasted address names the shown pane however its id is cased.
|
||||
await b.navigate('about:blank');
|
||||
await b.navigate(base + `#/assistant?open=alpha:${T.a1},beta:${T.b1}&show=beta:${T.b1.toUpperCase()}`);
|
||||
await wait(`${ready(`beta:${T.b1}`)}`);
|
||||
assert.deepEqual(await b.evaluate(shown), [`beta:${T.b1}`]);
|
||||
});
|
||||
|
||||
test('"Open thread" starts a new thread in a chosen organization through that organization\'s own route', async t => {
|
||||
const state = { down: new Set(['home']), sent: [], created: [] };
|
||||
const { b, wait } = await open(t, { hash: '#/assistant', state });
|
||||
await wait(`!!document.querySelector('[data-assist-open] option[value="new:beta"]')`);
|
||||
await pick(b, 'new:beta');
|
||||
await wait(`!!document.querySelector('[data-assist-new] select[name=project] option[value="${P.beta}"]') && !!document.querySelector('[data-assist-new] select[name=instance] option[value=assistant]')`);
|
||||
await b.evaluate(`(() => { const f = document.querySelector('[data-assist-new]'); f.elements.name.value = 'Rate limits'; f.requestSubmit(); })()`);
|
||||
await wait(`${ready(`beta:${NEW}`)} || !!${PANE(`beta:${NEW}`)}?.querySelector('form textarea')`);
|
||||
assert.deepEqual(state.created.map(({ org, project, name, instance }) => ({ org, project, name, instance })), [{ org: 'beta', project: P.beta, name: 'Rate limits', instance: 'assistant' }]);
|
||||
assert.equal(await b.evaluate(query), `beta:${NEW}`);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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", '<a data-late href="#/docs/example">late</a><a data-late-api href="/api/files/x">file</a>')`);
|
||||
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']);
|
||||
});
|
||||
@@ -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 => {
|
||||
|
||||
Reference in new issue
Block a user