feat(webui): the Assistant page holds up to three threads from any organization side by side, Side by side removed (#1873) #1970

Merged
jason.woltje merged 4 commits from sp6-threads into refactor 2026-11-15 11:26:14 +00:00
16 changed files with 952 additions and 1094 deletions

No files matched your search

+78
View File
@@ -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)}
}
+458
View File
@@ -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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 });
})();
+1 -1
View File
@@ -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');
+13 -9
View File
@@ -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)}` }));
+105 -63
View File
@@ -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 });
})();
+1 -1
View File
@@ -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()));
+3 -2
View File
@@ -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;
+2 -2
View File
@@ -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>
-93
View File
@@ -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}
}
-484
View File
@@ -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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 });
})();
+2 -2
View File
@@ -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}`);
});
+2 -2
View File
@@ -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 => {