Compare commits

...
4 Commits
Author SHA1 Message Date
jason.woltje 52a82a4e56 test(webui): U5 Agents panes keep their own project choice (#1873)
The rebase onto U3 brought in A1's "Available in a project" chooser. Its
change handler kept one renderer and wrote into the first table in the
document, keyed by the page's organization. The rebase resolution keys
the renderer and the choice by each grid's organization and redraws the
table in the chooser's own section. This test covers it: before that
change, pane b's choice never redrew pane b.
2026-11-10 14:07:05 -06:00
jason.woltje 5f3706e807 fix(webui): U5 pane view state survives a project repaint (#1873)
A pane keeps its request choice and PRD disclosure on the pane, per
organization, instead of on the project content element. A new
conversation changes the project header, which replaces that element;
the choice now survives it.
2026-11-10 14:07:05 -06:00
jason.woltje 5f57008aed fix(webui): U5 review fixes for side by side panes (#1873)
One observer per pane element, disconnected on leaving the page, so a
closed and reopened pane rewrites links for its current organization.
Project reads check the pane generation and organization before any
DOM write. Request selection and expanded PRDs are kept per pane.
A late navigation focuses its heading only when focus has not moved to
the other pane. Project pages in a pane use a compact layout with
container queries.
2026-11-10 14:07:05 -06:00
jason.woltje bc3e0a9f49 feat(webui): U5 side by side, two panes across organizations and projects (#1873)
The Side by side page now opens two panes. Each pane shows an organization
dashboard, an agents grid or a project page, chosen from a picker that lists
every organization and project. Pane state lives in the URL and is kept per
viewer. A separator resizes the panes by pointer or arrow keys, closing a
pane goes back to one, and each pane keeps its own focus and scroll through
polling. A pane whose organization's bus is down says so while the other
pane keeps working. On a phone a two-way switch shows one pane at a time.

Panes are read-only. Conversation, room, settings, queue, docs and inbox
pages open in full view; chat.js is a single-instance page with global ids.
2026-11-10 14:07:05 -06:00
10 changed files with 966 additions and 46 deletions

No files matched your search

+9 -1
View File
@@ -1449,7 +1449,6 @@
if (n === 0 && window.MosaicOrg) return window.MosaicWorkspace.home();
if (a === 'projects' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.projectsPage(q);
if (a === 'organization' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.dashboard();
if (a === 'side-by-side' && n === 1 && window.MosaicOrg) return `${head('Side by side', 'Not built yet.')}${empty('Side by side is not built yet.', 'It arrives with a later unified workspace update. Projects and Agents work today.')}`;
if (a === 'inbox' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.inboxPage(q);
if (a === 'tasks' && n === 1 && window.MosaicOrg) return window.MosaicTasksBoard.view();
if (a === 'inbox' && n === 1) return inboxView(get);
@@ -1542,6 +1541,15 @@
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) {
const fresh = navigated || shown !== path;
shown = path;
if (fresh) { unmountEditor(); await window.MosaicPanes.mount($('bus-view'), query, { navigated }); }
else window.MosaicPanes.refresh();
return;
}
if (chatPath === '/chat' || chatPath.startsWith('/chat/') || /^\/projects\/[0-9a-f-]+\/(?:settings|conversations|tasks|requirements|files)$/i.test(chatPath)) {
// Re-read the scoped capability as the Console and bus recover. A failed read
// withdraws the control; the chat renderer retains the draft across repaint.
+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>
<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><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="/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="/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><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>
<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>
+83
View File
@@ -0,0 +1,83 @@
/* 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)}
}
/* 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}
}
+477
View File
@@ -0,0 +1,477 @@
// 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);
body.innerHTML = html; p.html = html; p.project = null;
rewrite(p, body);
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) });
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 });
}
// 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 });
})();
+28 -16
View File
@@ -31,12 +31,15 @@
let treeRows=[], treeThreads=[], treeGeneration=0, treeState, treeRead=null, treeReadAt=0;
const treeFocus = new WeakMap();
try { treeState=JSON.parse(localStorage.getItem(storageKey) || '{}'); if (!treeState || Array.isArray(treeState) || typeof treeState !== 'object') treeState={}; } catch { treeState={}; }
// A side-by-side pane reads its own organization: its reads name that organization explicitly.
const at=(org,url)=>org && url.startsWith('/api/') ? `/api/orgs/${encodeURIComponent(org)}/${url.slice(5)}` : url;
async function read(url) {
const response=await fetch(url,{cache:'no-store',redirect:'manual'});
if(response.status===401 || response.type==='opaqueredirect') { document.dispatchEvent(new CustomEvent('mosaic-signed-out',{detail:{signIn:true}})); throw Error('signed-out'); }
if(!response.ok) { const body=await response.json().catch(()=>null); const error=Error('unavailable'); error.status=response.status; error.code=body?.error; throw error; }
return response.json();
}
const readAt=url=>read(url);
function currentProject(route) {
const id=route.match(/^\/(?:projects|chat)\/(?:projects\/|rooms\/)?([0-9a-f-]{36})/i)?.[1];
if(id) return id;
@@ -104,8 +107,9 @@
const same=(a,b)=>JSON.stringify(canonical(a))===JSON.stringify(canonical(b));
const material=(d,s)=>d && d.candidate && s?.candidate && same(d.candidate,s.candidate) && d.candidate.request===s.request && d.candidate.project===s.project && d.candidate.mode_revision===s.mode_revision && typeof d.prd==='string' && Array.isArray(d.requirements) && Array.isArray(d.candidate.requirements) && d.requirements.length===d.candidate.requirements.length && d.requirements.every((r,i)=>r && r.id===d.candidate.requirements[i]?.id && r.title===d.candidate.requirements[i]?.title && typeof r.text==='string') && d.snapshot && Number.isSafeInteger(d.snapshot.revision) && d.snapshot.revision>=0 && d.snapshot.id===s.snapshot && (d.snapshot.id===null || requestId(d.snapshot.id) && d.snapshot.revision>0);
const ownThreads=(project,threads)=>threads.filter(t=>t.project===project.id && !t.shared && UUID.test(t.id));
async function gather(project,threads) {
const own=ownThreads(project,threads), key=project.id+':'+String(project.repo)+':'+own.map(t=>t.id).join(',');
async function gather(project,threads,org=null) {
const read=url=>readAt(at(org,url));
const own=ownThreads(project,threads), key=(org?org+'|':'')+project.id+':'+String(project.repo)+':'+own.map(t=>t.id).join(',');
const prior=cache.get(key); if(prior && Date.now()-prior.at<10000) return prior.promise;
const started=epoch;
const promise=(async()=>{
@@ -133,7 +137,7 @@
const [draft,workspace]=await Promise.all([read(base+'/draft').catch(()=>null),read(base+'/workspace').catch(()=>null)]);
return {...item,state,draft:material(draft,state)?draft:null,materialFailed:!!state.candidate && !material(draft,state),workspace};
}))).filter(Boolean);
const [tasks,catalog,room]=await Promise.all([read('/api/bus/tasks').catch(()=>null),read('/api/chat/instances').catch(()=>null),readRoomStatus(project)]);
const [tasks,catalog,room]=await Promise.all([read('/api/bus/tasks').catch(()=>null),read('/api/chat/instances').catch(()=>null),readRoomStatus(project,org)]);
const members=Array.isArray(catalog?.rows)?catalog.rows.filter(m=>typeof m?.instance==='string' && own.some(t=>t.instance===m.instance && t.name===t.instance)).length:null;
let docs=project.repo ? null : {files:[]}, north=null, recordsMissing=false, northMissing=false, northRecordsMissing=false;
if(project.repo && /^[A-Za-z0-9][A-Za-z0-9._/-]{0,200}$/.test(project.repo)) {
@@ -145,9 +149,9 @@
})();
cache.set(key,{at:Date.now(),promise}); return promise;
}
async function readRoomStatus(project) {
async function readRoomStatus(project,org=null) {
try {
const response=await fetch(`/api/chat/rooms/${encodeURIComponent(project.id)}/messages?limit=100`,{cache:'no-store',redirect:'manual'});
const response=await fetch(at(org,'/api/chat/rooms/')+`${encodeURIComponent(project.id)}/messages?limit=100`,{cache:'no-store',redirect:'manual'});
if(response.status===401 || response.type==='opaqueredirect') { document.dispatchEvent(new CustomEvent('mosaic-signed-out',{detail:{signIn:true}})); return 'Unavailable'; }
const body=await response.json();
if(response.ok && Array.isArray(body?.rows) && typeof body.hasMore==='boolean') return 'Open';
@@ -194,11 +198,18 @@
return `<h2>Reviewed deliveries <span class="project-count">${f.reviewed.length}</span></h2>${f.reviewed.length?f.reviewed.map(({r,file})=>`<div class="project-panel"><span class="project-delivery-icon" aria-hidden="true">▤</span><strong>${clean(file.name)}</strong> <span class="project-chip success">Reviewed</span><p>Request ${clean(r.id)} · from ${clean(r.thread.name)} · Reviewed delivery. Not landed or deployed.</p><div class="project-delivery-actions"><a class="btn" target="_blank" rel="noopener noreferrer" href="/api/requests/${encodeURIComponent(r.id)}/artifact/${encodeURIComponent(file.id)}/text">Open as text</a> <a class="btn" download href="/api/requests/${encodeURIComponent(r.id)}/artifact/${encodeURIComponent(file.id)}">Download</a>${deliveryPr(r)?` <a class="btn" target="_blank" rel="noopener noreferrer" href="${esc(deliveryPr(r))}">Open pull request</a>`:''}</div><p>Open shows plain text in a new tab. Downloaded HTML can be opened locally after saving.</p></div>`).join(''):`<p>${data.disabled?'Files are unavailable.':data.failed?'Reviewed files are unavailable.':data.requests.length?'No reviewed file is available.':'No request files yet. Files appear when a request in this project produces them.'}</p>`}<h2>Draft workspace <span class="project-count">${f.drafts.length}</span></h2><p>Names only. Draft files may change and are not reviewed deliveries.</p><div class="project-panel project-file-list">${f.drafts.length?f.drafts.map(({r,name})=>`<div><span><span aria-hidden="true">▤</span> <code>${clean(name)}</code></span><span>Request ${clean(r.id)} · <span class="project-chip">Draft</span></span></div>`).join(''):data.failed || data.requests.some(r=>r.workspace?.available!==true || !Array.isArray(r.workspace.entries))?'<p>Could not read draft workspace. Try again later.</p>':'<p>No draft files found.</p>'}${data.requests.some(r=>r.workspace?.truncated)?'<p>The draft list was truncated.</p>':''}</div><h2>Project documents ${docs?`<span class="project-count">${docs.length}</span>`:''}</h2><div class="project-panel project-file-list">${!project.repo?'No linked repository.':data.recordsMissing?window.MosaicRecords.markup(project.repo):!docs?'Project documents unavailable. Docs could not read this project’s repository.':docs.length?docs.map(name=>`<div><span><span aria-hidden="true">▤</span> <code>${clean(name)}</code></span><a href="#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent(name)}">Open in Docs</a></div>`).join(''):'No project documents found.'}</div>`;
}
const expandedPrds = new Set();
function requirementView(data,project,selected) {
// U5: a project in a Side by side pane keeps its own request choice and PRD disclosure, so two
// panes showing the same project (or two organizations' projects with one id) never share them.
// The pane passes `views`, kept on the pane per organization, so the state outlives a repaint
// that replaces the content element (a new conversation changes the header). Without it, the
// state lives as long as the container.
const paneViews = new WeakMap();
const viewState=(container,pane,views)=>{if(!pane) return {selected:selectedRequests,expanded:expandedPrds}; if(views?.selected instanceof Map && views.expanded instanceof Set) return views; if(!paneViews.has(container)) paneViews.set(container,{selected:new Map(),expanded:new Set()}); return paneViews.get(container);};
function requirementView(data,project,selected,expandedSet=expandedPrds) {
const options=data.requests.filter(r=>r.draft), r=options.find(x=>x.id===selected)||options[0], d=r?.draft;
const north=typeof data.north?.content==='string'?data.north.content:null;
const select=options.length>1?`<label>Request <select id="project-request-select">${options.map(x=>`<option value="${clean(x.id)}"${x===r?' selected':''}>${clean(x.id)} · ${clean(x.thread.name)}</option>`).join('')}</select></label>`:'';
const candidate=d?.candidate, expanded=r && expandedPrds.has(project.id+':'+r.id);
const candidate=d?.candidate, expanded=r && expandedSet.has(project.id+':'+r.id);
const docPath=candidate?.prd_path, docLink=project.repo && typeof docPath==='string' && /^docs\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.md$/.test(docPath) ? `#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent(docPath)}` : null;
const rows=candidate ? [['PRD version',candidate.prd_version],['Approved snapshot',d.snapshot.id || 'None'],['PRD file',docPath],['PRD blob',candidate.prd_blob],['Records commit',candidate.records_commit],['Confirmed intent commit',candidate.spec_commit],['Mode revision',candidate.mode_revision]].map(([label,value])=>`<div class="project-provenance-row"><span>${label}</span>${label==='PRD file' && docLink?`<a href="${docLink}"><code>${clean(value)}</code></a>`:`<code>${clean(value)}</code>`}</div>`).join('') : '<p>Unavailable until a PRD is read.</p>';
return `${r?`<div class="project-panel project-source">${select || `Request <code>${clean(r.id)}</code>`} · ${chip(r.state)} · from ${clean(r.thread.name)} <a href="${conversationLink(r.thread)}">Open conversation</a></div>`:select}<div class="project-columns"><section><h2>Project direction</h2><h3 id="project-north-star">North star</h3>${north!==null?`<div data-north-star></div><a class="project-text-link" href="#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent('spec/north-star.md')}">Open north star in Docs</a>`:data.northRecordsMissing?`<p>${window.MosaicRecords.markup(project.repo)}</p>`:data.northMissing?'<p>No north star yet.</p>':project.repo?'<p>North star unavailable. Docs could not read this project’s repository.</p>':`<p>This project has no linked repository. The north star and project documents live in a repository. Linking one to an existing project isn't available yet.</p>`}<h3 id="project-prd">${candidate?`PRD ${clean(candidate.prd_version)}`:'PRD'} ${candidate?`<span class="project-chip ${d.snapshot.id?'success':''}">${d.snapshot.id?'Approved':'Draft'}</span>`:''}</h3>${d?`<p>${d.snapshot.id?'Approved snapshot '+clean(d.snapshot.id):'This candidate has no active approved snapshot.'}</p><div class="project-panel project-prd-panel"><div class="project-prd-preview${expanded?' expanded':''}" data-prd></div><button type="button" class="btn project-prd-toggle" aria-expanded="${!!expanded}">${expanded?'Show less':'Show full PRD'}</button></div><h3>Requirements <span class="project-count">${d.requirements.length}</span></h3><div class="project-panel">${d.requirements.map((req,i)=>`<details data-restore-key="req:${clean(r.id)}:${clean(req.id)}"><summary>${clean(req.id)} · ${clean(req.title)}</summary><p>${clean(req.text)}</p><p>Requirement identity: <code>${clean(candidate.requirements[i]?.path)}</code> · <code>${clean(candidate.requirements[i]?.blob)}</code></p></details>`).join('')}</div>`:`<p>${(data.failed || data.requests.some(x=>x.materialFailed))?'Request or PRD unavailable. Refresh to try again.':'No current PRD candidate or approved snapshot. Waiting for a PRD submission.'}</p>`}${d && data.requests.some(x=>x.materialFailed)?'<p role="status">Some request PRDs could not be read. Refresh to try again.</p>':''}</section><aside class="project-panel project-provenance"><details data-restore-key="provenance"${matchMedia('(min-width:700px)').matches?' open':''}><summary>Version and provenance</summary>${rows}${candidate?`<p>Approval and revocation live in the conversation's Request details.</p><a href="${conversationLink(r.thread)}">Open conversation</a>`:''}</details></aside></div>`;
@@ -241,31 +252,32 @@
const wanted=new Set((restore.disclosures || []).map(String)), closed=new Set((restore.closed || []).map(String));
[...root.querySelectorAll('details')].forEach((el,i)=>{const key=restoreKey(el,i);if(wanted.has(key)) el.open=true;else if(el.dataset.restoreKey && closed.has(key)) el.open=false;});
const f=restore.projectFocus; if(!f) return;
const focus=f.id ? document.getElementById(f.id) : f.details ? [...root.querySelectorAll('details[data-restore-key]')].find(el=>el.dataset.restoreKey===f.details)?.querySelector(':scope > summary') : root.querySelectorAll(FOCUSABLE)[f.index];
const focus=f.id ? root.querySelector('#'+CSS.escape(f.id)) : f.details ? [...root.querySelectorAll('details[data-restore-key]')].find(el=>el.dataset.restoreKey===f.details)?.querySelector(':scope > summary') : root.querySelectorAll(FOCUSABLE)[f.index];
focus?.focus({preventScroll:true});
}
async function mountContent({container,project,threads,section,canSend,restore,roomReceipt}) {
async function mountContent({container,project,threads,section,canSend,restore,roomReceipt,org=null,pane=false,views:paneState=null}) {
if(!container || !project) return;
const generation=epoch, run=(contentRuns.get(container) || 0)+1;
contentRuns.set(container,run);
if (!contentPaints.has(container)) container.textContent='Reading…';
const data=await gather(project,threads).catch(()=>null);
const data=await gather(project,threads,org).catch(()=>null);
if(!container.isConnected || generation!==epoch || contentRuns.get(container)!==run || document.body.classList.contains('signed-out')) return;
if(!data){contentPaints.delete(container);container.textContent='Project data unavailable. Refresh to try again.';return;}
const views=viewState(container,pane,paneState), selectedRequests=views.selected, expandedPrds=views.expanded;
const selected=selectedRequests.get(project.id);
if(selected && !data.requests.some(r=>r.id===selected && r.draft)) selectedRequests.delete(project.id);
const selection=selectedRequests.get(project.id);
const view = section==='requirements'?requirementView(data,project,selection):section==='files'?filesView(data,project):section==='tasks'?tasksView(data):overview(data,project,canSend,roomReceipt);
const view = section==='requirements'?requirementView(data,project,selection,expandedPrds):section==='files'?filesView(data,project):section==='tasks'?tasksView(data):overview(data,project,canSend,roomReceipt);
const selectedRequest=data.requests.find(r=>r.id===(selection||data.requests.find(x=>x.draft)?.id));
// The generated presentation and separately mounted Markdown determine visible content.
const signature=JSON.stringify([view, section==='requirements' ? selectedRequest?.draft?.prd : null, ['requirements','overview'].includes(section) ? data.north?.content : null, data.failed && ['files','tasks'].includes(section)]);
const header=container.closest('.chat-area')?.querySelector('.project-tabs');
if(header){const tasks=header.querySelector(`a[href="${path(project.id,'tasks')}"]`), files=header.querySelector(`a[href="${path(project.id,'files')}"]`);const f=filesData(data);if(tasks && !tasks.querySelector('.project-count') && !data.failed)tasks.insertAdjacentHTML('beforeend',` <span class="project-count">${taskRefs(data).length}</span>`);if(files && !files.querySelector('.project-count') && !data.failed && f.docs && data.requests.every(r=>r.workspace?.available===true && Array.isArray(r.workspace.entries)))files.insertAdjacentHTML('beforeend',` <span class="project-count">${f.reviewed.length+f.drafts.length+f.docs.length}</span>`);}
if(header){const tab=key=>header.querySelector(`a[href$="${path(project.id,key).slice(1)}"]`), tasks=tab('tasks'), files=tab('files');const f=filesData(data);if(tasks && !tasks.querySelector('.project-count') && !data.failed)tasks.insertAdjacentHTML('beforeend',` <span class="project-count">${taskRefs(data).length}</span>`);if(files && !files.querySelector('.project-count') && !data.failed && f.docs && data.requests.every(r=>r.workspace?.available===true && Array.isArray(r.workspace.entries)))files.insertAdjacentHTML('beforeend',` <span class="project-count">${f.reviewed.length+f.drafts.length+f.docs.length}</span>`);}
revealTab(header);
// Retained controls (the request selector) draw from the newest accepted read, even when
// this read changes nothing visible and the DOM is kept.
contentModels.set(container,{data,project,section,canSend,roomReceipt});
window.MosaicOnboarding?.offer(container,{project,section,north:data.north,docs:data.docs,threads:data.threads,canSend}); // F2: its panel sits before the container, outside draw().
if(!pane) window.MosaicOnboarding?.offer(container,{project,section,north:data.north,docs:data.docs,threads:data.threads,canSend}); // F2: its panel sits before the container, outside draw().
if (contentPaints.get(container) === signature) return;
contentPaints.set(container, signature);
// Capture at commit time: the user may have interacted while the read was pending.
@@ -273,7 +285,7 @@
const liveRestore=contentPaints.has(container) && container.children.length ? captureView(container) : null;
const draw=selected=>{
const {data,project,section,canSend,roomReceipt}=contentModels.get(container);
container.innerHTML=section==='requirements'?requirementView(data,project,selected):section==='files'?filesView(data,project):section==='tasks'?tasksView(data):overview(data,project,canSend,roomReceipt);
container.innerHTML=section==='requirements'?requirementView(data,project,selected,expandedPrds):section==='files'?filesView(data,project):section==='tasks'?tasksView(data):overview(data,project,canSend,roomReceipt);
if(data.failed && ['files','tasks'].includes(section)) container.insertAdjacentHTML('afterbegin','<p role="status">Some request data could not be read; this list may be incomplete.</p>');
const selectedRequest=data.requests.find(r=>r.id===(selected||data.requests.find(x=>x.draft)?.id));
const md=container.querySelector('[data-prd]'); if(md && selectedRequest?.draft) { md.replaceChildren(window.MosaicMarkdown?.render(selectedRequest.draft.prd) || document.createTextNode(selectedRequest.draft.prd)); const toggle=container.querySelector('.project-prd-toggle'); toggle.hidden=!md.classList.contains('expanded') && md.scrollHeight<=md.clientHeight; toggle.addEventListener('click',()=>{const key=project.id+':'+selectedRequest.id; if(expandedPrds.has(key)) expandedPrds.delete(key); else expandedPrds.add(key); const open=expandedPrds.has(key); md.classList.toggle('expanded',open); toggle.setAttribute('aria-expanded',String(open));toggle.textContent=open?'Show less':'Show full PRD';}); }
@@ -285,7 +297,7 @@
if (restore) {
restoreView(container,restore);
}
const sectionTarget=new URLSearchParams(location.hash.split('?')[1] || '').get('section');
const sectionTarget=pane ? null : new URLSearchParams(location.hash.split('?')[1] || '').get('section');
if(section==='requirements' && ['prd','north-star'].includes(sectionTarget)) container.querySelector('#project-'+sectionTarget)?.scrollIntoView({block:'start'});
}
// Cut at a word boundary, unless that would leave only a markdown marker (a long heading).
@@ -334,7 +346,7 @@
}
const palette=()=>treeRows.filter(p=>!p.archived && UUID.test(p.id)).flatMap(p=>sections.map(([key,label])=>({href:path(p.id,key),label:key==='overview'?p.name:`${p.name}: ${label}`,group:'project'})));
function breadcrumb(box, view) {
const h=view.querySelector('.project-header'); if(!h)return false;
const h=view.querySelector('.project-header'); if(!h||h.closest('[data-pane]'))return false;
const id=h.dataset.projectId, name=h.dataset.projectName, section=h.dataset.projectSection, conversation=h.dataset.projectConversation==='true';
const title=sections.find(([key])=>key===section)?.[1] || (section==='settings'?'Settings':'Overview');
const leaf=conversation?view.querySelector('h1')?.textContent:title;
+21 -16
View File
@@ -191,8 +191,10 @@
}).join('')}</ul>`;
}
async function dashboard() {
const id = window.MosaicOrg.id;
// A side-by-side pane passes its own organization and { pane: true, idPrefix }: the pane has no bus
// restart (that stays in the organization's full view) and its ids carry the pane's prefix.
async function dashboard(id = window.MosaicOrg.id, opts = {}) {
const pane = opts.pane === true, why = `${opts.idPrefix || ''}ws-reload-why`;
// Only an organization the membership list names is read; anything else reads nothing more.
const [listRead] = await Promise.all([data.organizations().catch(() => null), ensureThemes()]);
const list = Array.isArray(listRead) ? listRead : null;
@@ -210,11 +212,12 @@
// Header: the bus restart is console-restart.js's button where the Console allows it, otherwise
// the same control, present and disabled, with the reason.
const restart = window.MosaicConsole?.busButton?.('Restart bus') || '';
const bus = restart ? `<span class="ws-busctl" data-dashboard-bus>${icon('restart', 'ws-busicon')}${restart}</span>` : `<span data-dashboard-bus><button type="button" class="ws-btn" data-bus-restart-off data-focus-key="bus-restart-off" disabled title="Restarting the bus is the owner's trailed action, on a Console started with send and the action trail">${icon('restart')}Restart bus</button></span>`;
const reload = `<button type="button" class="ws-btn" data-org-reload data-focus-key="org-reload" disabled aria-describedby="ws-reload-why" title="Reload arrives with the cross-org update">${icon('doc')}Reload organization file</button>`;
const restart = pane ? '' : window.MosaicConsole?.busButton?.('Restart bus') || '';
const offTitle = pane ? "Restart the bus from this organization's full view" : "Restarting the bus is the owner's trailed action, on a Console started with send and the action trail";
const bus = restart ? `<span class="ws-busctl" data-dashboard-bus>${icon('restart', 'ws-busicon')}${restart}</span>` : `<span data-dashboard-bus><button type="button" class="ws-btn" data-bus-restart-off data-focus-key="bus-restart-off" disabled title="${esc(offTitle)}">${icon('restart')}Restart bus</button></span>`;
const reload = `<button type="button" class="ws-btn" data-org-reload data-focus-key="org-reload" disabled aria-describedby="${esc(why)}" title="Reload arrives with the cross-org update">${icon('doc')}Reload organization file</button>`;
const [bcls, bglyph] = state(org);
const meta = `<p class="ws-meta"><span>Kind not recorded</span><span>id <code>${esc(id)}</code></span><span>Tracker: ${tracker ? esc(titleCase(tracker)) : 'not set'}</span><span>${dot(bcls, bglyph, org.busHealthy ? `bus running, read ${clock()}` : `bus unavailable, read ${clock()}`, true)}</span><span id="ws-reload-why">Restart is a trailed human action; reload arrives with the cross-org update.</span></p>`;
const meta = `<p class="ws-meta"><span>Kind not recorded</span><span>id <code>${esc(id)}</code></span><span>Tracker: ${tracker ? esc(titleCase(tracker)) : 'not set'}</span><span>${dot(bcls, bglyph, org.busHealthy ? `bus running, read ${clock()}` : `bus unavailable, read ${clock()}`, true)}</span><span id="${esc(why)}">Restart is a trailed human action; reload arrives with the cross-org update.</span></p>`;
const projectName = pid => projectRows?.find(p => p.id === pid)?.name || pid;
const typechips = '<div class="ws-typechips ws-chips"><span class="ws-label">System types</span><span class="note">No system types yet. They arrive with the system types update.</span></div>';
@@ -269,7 +272,9 @@
const LEVEL_CLS = { root: 'root', org: 'org', project: 'proj' };
const projectName = (rows, id) => { const p = Array.isArray(rows) ? rows.find(r => r?.id === id) : null; return p?.name || id; };
let agentsPick = null; // the project chosen in "Available in a project", kept across repaints
let agentsAvailable = null; // repaints that section alone when the choice changes
// Repaints that section alone when the choice changes, one renderer per organization: a side-by-side
// pane can show another organization's agents next to the page's own.
let agentsAvailable = {};
function levelsTable(ctx, projectId) {
const { selOrg, lv, mine, levels, modelOf, names, others } = ctx;
@@ -322,9 +327,8 @@
return `<div class="ws-tbl"><table><thead><tr><th scope="col">Agent</th><th scope="col">Level</th><th scope="col" class="hide-m">Defined in</th><th scope="col" class="hide-m">Memory</th><th scope="col">Status</th><th scope="col" class="hide-m">Model</th></tr></thead><tbody>${body}</tbody></table></div>${note}`;
}
async function agentGrid(note = '') {
async function agentGrid(note = '', selected = window.MosaicOrg?.id) {
const [list, levels] = await Promise.all([data.organizations().catch(() => null), data.agentLevels().catch(() => null), ensureThemes()]);
const selected = window.MosaicOrg?.id;
const head = `<div class="ws-vh"><h1 tabindex="-1">Agents</h1><div class="ws-right"><a class="ws-btn" href="#/settings/agents" data-focus-key="agents-edit">${icon('settings')}Edit agents</a></div><p class="sub">Agents are defined at three levels: root, organization and project. Root and project agents are read from their files and are not launched yet.${note ? ` ${note}` : ''}</p></div>`;
if (!Array.isArray(list)) return `<section class="ws ws-agents" data-org-agents>${head}<p class="ws-panel tight note">Organizations unavailable.</p></section>`;
const valid = list.filter(org => !org.invalid);
@@ -372,8 +376,8 @@
const projectIds = lv?.state === 'ok' ? Object.keys(lv.projects) : [];
const pick = projectIds.includes(agentsPick?.[selected]) ? agentsPick[selected] : projectIds[0] ?? null;
const ctx = { selOrg, lv, mine, levels, modelOf, names: id => projectName(mine?.rows, id), others: valid.filter(o => o.id !== selected) };
agentsAvailable = projectId => levelsTable(ctx, projectId);
const chooser = projectIds.length ? `<label class="ws-psel">${av(selOrg, 'xs')}<span class="ws-sr">Project</span><select data-agents-project data-focus-key="agents-project">${projectIds.map(id => `<option value="${esc(id)}"${id === pick ? ' selected' : ''}>${esc(ctx.names(id))}</option>`).join('')}</select></label>` : '';
agentsAvailable = { ...agentsAvailable, [selected]: projectId => levelsTable(ctx, projectId) };
const chooser = projectIds.length ? `<label class="ws-psel">${av(selOrg, 'xs')}<span class="ws-sr">Project</span><select data-agents-project="${esc(selected)}" data-focus-key="agents-project">${projectIds.map(id => `<option value="${esc(id)}"${id === pick ? ' selected' : ''}>${esc(ctx.names(id))}</option>`).join('')}</select></label>` : '';
const title = projectIds.length ? 'Available in a project' : `Agents in ${esc(label(selOrg))}`;
const table = !mine ? '<p class="ws-panel tight note">The selected organization is not on the list.</p>' : levelsTable(ctx, pick);
const rules = `<div class="ws-panel tight ws-rules"><p><span class="ws-lvl root">root</span>You define root agents in agents.json in your configuration directory. The chief cannot change them.</p><p><span class="ws-lvl org">org</span>Agents come from the organization file. Add, change or remove them under Settings, Agents.</p><p><span class="ws-lvl proj">project</span>A project in the organization file can add an agent, narrow an inherited agent's limits or exclude one. It never widens them.</p><p><span class="ws-lvl proj">override</span>The most specific level wins. A row says which definition it replaces.</p><p class="muted">An agent's harness, model and thinking are set under Settings, Routing.</p></div>`;
@@ -383,10 +387,11 @@
}
document.addEventListener('change', e => {
const select = e.target.closest?.('[data-agents-project]');
const box = select && document.querySelector('[data-agents-available]');
if (!box || !agentsAvailable || !window.MosaicOrg) return;
agentsPick = { ...agentsPick, [window.MosaicOrg.id]: select.value };
box.innerHTML = agentsAvailable(select.value);
const org = select?.dataset.agentsProject, draw = org && Object.hasOwn(agentsAvailable, org) ? agentsAvailable[org] : null;
const box = draw && select.closest('[data-org-agents]')?.querySelector('[data-agents-available]');
if (!box) return;
agentsPick = { ...agentsPick, [org]: select.value };
box.innerHTML = draw(select.value);
});
// ---------- across organizations (U3: samples 02, 04 and 05) ----------
@@ -737,6 +742,6 @@
// U4b: the org mark (colour and initials) for the task board (tasks-board.js).
const orgMark = Object.freeze({ ready: ensureThemes, av, colours, initials });
window.MosaicWorkspace = Object.freeze({ dashboard, agentGrid, icon, orgMark, refresh: () => refresh(), home, projectsPage, inboxPage, setGrouping, get pollMs() { return pollMs; } });
window.MosaicWorkspace = Object.freeze({ dashboard, agentGrid, icon, orgMark, refresh: () => refresh(), av, colours, label, state, dot, themes: ensureThemes, home, projectsPage, inboxPage, setGrouping, get pollMs() { return pollMs; } });
refresh();
})();
+2 -2
View File
@@ -60,12 +60,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'].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', 'panes.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'].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', 'panes.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']],
+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.
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', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.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) 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', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/panes.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);
@@ -0,0 +1,340 @@
// 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('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] #project-request-select')].map(e => e.value)`;
await wait(`document.querySelectorAll('[data-pane-body] #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'`);
await b.evaluate('window.MosaicPanes.refresh()');
await settled(b);
await wait(`document.querySelectorAll('[data-pane-body] #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] #project-request-select').length === 2 && document.querySelectorAll('[data-pane-body] .project-prd-toggle').length === 2`);
const state = `({ selected: [...document.querySelectorAll('[data-pane-body] #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] #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);
});
@@ -110,13 +110,8 @@ test('sidebar follows sample 01 and Queue opens from the organization dashboard,
assert.equal(queue, '#/queue');
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")');
// Views not built yet open and say so.
// Inbox (U3, workspace-pages-browser.test.mjs) and Tasks (U4b, tasks-board-browser.test.mjs) are built.
for (const [route, title] of [['side-by-side', 'Side by side']]) {
await b.evaluate(`location.hash = "#/orgs/ddigital/${route}"`);
await wait(`document.getElementById("bus-view").innerText.includes("not built yet") && [...document.querySelectorAll("#bus-view h1")].some(h => h.textContent.trim() === ${JSON.stringify(title)})`);
assert.doesNotMatch(await b.evaluate('document.getElementById("bus-view").innerText'), /error|failed/i, route);
}
// 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).
});
test('organization dashboard renders every section from the per-organization reads', async t => {