fix(webui): sp7 polish, a system page names itself once, thread links open as Assistant panes, each pane links its organization's messages, resume is a small control, Inbox class chips use words, a stopped organization's page has one restart (#1873, lane sp7-polish)
- A system page with nothing above it in the trail hides the trail that repeats its h1; drill-in trails stay. - A project's thread link and each open-in-chat link add that thread as a pane on #/assistant. With three open, the first pane without an unsent message is replaced; when all three hold one, nothing is replaced and the page says why. Per-org chat addresses still work typed or pasted. - "Messages in <org>" moves from the bar into each pane header as a link. - The pane composer's Resume assistant is a small secondary control; behaviour unchanged. - Inbox class chips read the one map of decision classes to words. - Removes the unused pane, idPrefix and views params from projects.js and workspace.js; fixes the stale comment on the held-message status line. - While an organization's bus is down, the header draws no bus control; the down banner's restart is the one.
This commit is contained in:
1 parent
789e2bcdd4
commit
a2a7df73df
13 files changed
+193
-57
No files matched your search
@@ -41,6 +41,11 @@ html[data-organizations] .assist-chips{display:inline-flex;gap:6px;flex-wrap:wra
|
||||
html[data-organizations] .assist-chips .ws-chip{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
html[data-organizations] .assist .pane-tool{display:inline-grid;place-items:center;width:28px;height:28px;border-radius:var(--r);color:var(--muted);border:0;background:none;padding:0;min-height:0;cursor:pointer;flex:none}
|
||||
html[data-organizations] .assist .pane-tool:hover{background:var(--raised);color:var(--text)}
|
||||
/* SP7: each pane's own organization's Messages page, a text link among the pane's tools. */
|
||||
html[data-organizations] .assist .pane-tool.assist-msgs{width:auto;padding:0 8px;font-size:12px;font-weight:600;text-decoration:none}
|
||||
/* SP7: "Resume assistant" under a pane's composer is a small secondary control, not a second primary button. */
|
||||
html[data-organizations] .assist-body .btn.pane-resume{align-self:flex-start;min-height:0;padding:2px 8px;margin-top:4px;font-size:12px;font-weight:500;background:none;border-color:transparent;color:var(--muted)}
|
||||
html[data-organizations] .assist-body .btn.pane-resume:hover:not(:disabled){background:var(--raised);color:var(--text)}
|
||||
html[data-organizations] .assist .pane-tool svg.i{width:15px;height:15px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
|
||||
html[data-organizations] .assist-line{margin:0;padding:6px 12px;border-bottom:1px solid var(--line);font-size:13px;color:var(--text);background:color-mix(in srgb,var(--warn,#b7791f) 12%,var(--surface))}
|
||||
html[data-organizations] .assist-line[hidden]{display:none}
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
// The open set, its order and the phone's shown pane live in the address
|
||||
// (#/assistant?open=<org>:<thread>,…&show=<org>:<thread>), checked before use and rewritten with
|
||||
// replaceState. A bare #/assistant opens the set this browser had last.
|
||||
//
|
||||
// SP7: a plain click on a thread link anywhere in the Console (a project's conversation rows, "Open
|
||||
// conversation") opens that thread as a pane here, added to the open set; #/assistant?add=<org>:<thread>
|
||||
// carries it across the page change. A per-organization chat address typed or pasted still opens
|
||||
// that organization's own chat view.
|
||||
(() => {
|
||||
'use strict';
|
||||
if (!document.documentElement.hasAttribute('data-organizations')) return;
|
||||
@@ -102,7 +107,6 @@
|
||||
<div class="assist-tools">
|
||||
<label class="ws-sr" for="assist-open">Open thread</label>
|
||||
<select id="assist-open" class="assist-open" data-assist-open><option value="">Open thread…</option></select>
|
||||
<a class="ws-btn" data-assist-messages href="#/messages">Messages</a>
|
||||
</div>
|
||||
</div>
|
||||
<div data-assist-unread></div>
|
||||
@@ -128,6 +132,7 @@
|
||||
<div class="assist-pane-h">
|
||||
<h2 id="assist-p${n}-title" data-assist-title>Reading…</h2>
|
||||
<span class="assist-chips" data-assist-chips></span>
|
||||
<a class="pane-tool assist-msgs" data-assist-pane-messages href="#/orgs/${enc(p.org)}/messages" aria-label="Messages in ${esc(p.org)}">Messages</a>
|
||||
<button type="button" class="pane-tool" data-assist-close aria-label="Close thread">${CLOSE}</button>
|
||||
</div>
|
||||
<p class="assist-line" data-assist-line role="status" hidden></p>
|
||||
@@ -136,7 +141,7 @@
|
||||
}
|
||||
const els = p => ({
|
||||
title: p.el.querySelector('[data-assist-title]'), chips: p.el.querySelector('[data-assist-chips]'),
|
||||
close: p.el.querySelector('[data-assist-close]'), line: p.el.querySelector('[data-assist-line]'), body: p.el.querySelector('[data-assist-body]'),
|
||||
close: p.el.querySelector('[data-assist-close]'), messages: p.el.querySelector('[data-assist-pane-messages]'), line: p.el.querySelector('[data-assist-line]'), body: p.el.querySelector('[data-assist-body]'),
|
||||
});
|
||||
|
||||
// The picker: every organization on the list, its threads, and "New thread in …" where this
|
||||
@@ -186,19 +191,12 @@
|
||||
function paintTop() {
|
||||
const { root, list, o, panes } = mounted;
|
||||
setHtml(root.querySelector('[data-assist-unread]'), list ? ws().unreadNote(o, [], 'assistant') : '<p class="note ws-unread-note">The organization list could not be read. Open panes only read.</p>');
|
||||
setText(root.querySelector('[data-assist-note]'), panes.length ? `${panes.length} open, up to ${MAX}` : '');
|
||||
setText(root.querySelector('[data-assist-note]'), mounted.notice || (panes.length ? `${panes.length} open, up to ${MAX}` : ''));
|
||||
const pick = root.querySelector('[data-assist-open]');
|
||||
if (document.activeElement !== pick) { setHtml(pick, pickerHtml(list, mounted.got)); pick.value = ''; }
|
||||
const full = panes.length >= MAX;
|
||||
pick.disabled = full;
|
||||
pick.title = full ? `${MAX} threads are open. Close one to open another.` : '';
|
||||
const here = window.MosaicOrg?.id, messages = root.querySelector('[data-assist-messages]');
|
||||
messages.hidden = !here;
|
||||
if (here) {
|
||||
const href = `#/orgs/${enc(here)}/messages`;
|
||||
if (messages.getAttribute('href') !== href) messages.setAttribute('href', href);
|
||||
setText(messages, `Messages in ${window.MosaicOrg.label || here}`);
|
||||
}
|
||||
root.querySelector('[data-assist-empty]').hidden = panes.length > 0;
|
||||
}
|
||||
function drawPane(p) {
|
||||
@@ -211,6 +209,7 @@
|
||||
p.title = title;
|
||||
setText(e.title, title);
|
||||
e.close.setAttribute('aria-label', `Close ${title}, ${name}`);
|
||||
e.messages.setAttribute('aria-label', `Messages in ${name}`);
|
||||
if (org) p.el.setAttribute('style', ws().colours(org)); else p.el.removeAttribute('style');
|
||||
const project = thread && i.projects?.find(x => x.id === thread.project)?.name;
|
||||
setHtml(e.chips, `${ws().orgChip(o, p.org)}${ws().projChip(project || '')}`);
|
||||
@@ -251,7 +250,8 @@
|
||||
if (seq !== p.seq || p.chat !== chat || !p.el.isConnected) return;
|
||||
const field = p.draft ? e.body.querySelector('form textarea') : null;
|
||||
if (p.draft && !field) {
|
||||
// The thread reads but offers no composer: the held message stays visible here, never silently held.
|
||||
// The thread reads but this render drew no composer (it cannot send now): the held message is
|
||||
// quoted in the pane's status line until a composer is back to take it.
|
||||
p.held = true; const text = withHeld(); setText(e.line, text); e.line.hidden = false;
|
||||
return;
|
||||
}
|
||||
@@ -308,10 +308,51 @@
|
||||
if (mounted.got) { paintTop(); drawPane(p); paintTabs(); layout(); }
|
||||
if (focus) els(p).title.setAttribute('tabindex', '-1'), els(p).title.focus({ preventScroll: true });
|
||||
}
|
||||
// SP7: a thread opened from a link takes a free slot or, with all three taken, the place of the
|
||||
// first pane that holds no unsent message. When every pane holds one, nothing is replaced.
|
||||
const holds = p => !!p.draft.trim() || !!p.chat?.hasDraft();
|
||||
function place(key) {
|
||||
const had = mounted.panes.find(p => p.key === key);
|
||||
if (had) return had;
|
||||
if (mounted.panes.length < MAX) { const p = makePane(key); mounted.panes.push(p); return p; }
|
||||
const old = mounted.panes.find(p => !holds(p));
|
||||
if (!old) return null;
|
||||
const p = makePane(key);
|
||||
old.chat?.dispose(); old.chat = null; old.el.replaceWith(p.el);
|
||||
mounted.panes[mounted.panes.indexOf(old)] = p;
|
||||
return p;
|
||||
}
|
||||
const REFUSED = `That thread was not opened: all ${MAX} panes hold an unsent message. Send or clear one, or close a pane, then open the thread again.`;
|
||||
function add(key) {
|
||||
if (!mounted) return false;
|
||||
const p = place(key);
|
||||
if (!p) { mounted.notice = REFUSED; setText(mounted.root.querySelector('[data-assist-note]'), REFUSED); return false; }
|
||||
mounted.notice = ''; mounted.show = key;
|
||||
writeUrl();
|
||||
if (mounted.got) { paintTop(); drawPane(p); paintTabs(); layout(); }
|
||||
els(p).title.setAttribute('tabindex', '-1'); els(p).title.focus({ preventScroll: true });
|
||||
return true;
|
||||
}
|
||||
const addOf = query => { const v = new URLSearchParams(query || '').get('add'); return v ? parse(new URLSearchParams({ open: v }).toString()).keys[0] || null : null; };
|
||||
const THREAD_LINK = /^#\/(?:orgs\/([^/?#]+)\/)?(?:projects|chat)\/threads\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
|
||||
document.addEventListener('click', e => {
|
||||
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||
const a = e.target.closest?.('a[href]');
|
||||
if (!a || a.hasAttribute('download') || (a.target && a.target !== '_self')) return;
|
||||
const m = THREAD_LINK.exec(a.getAttribute('href') || '');
|
||||
const org = m && (m[1] || window.MosaicOrg?.id);
|
||||
if (!m || !org || !ORG.test(org)) return;
|
||||
e.preventDefault();
|
||||
const key = `${org}:${m[2].toLowerCase()}`;
|
||||
if (mounted && /^#\/assistant(?:\?|$)/.test(location.hash)) add(key);
|
||||
else location.hash = `#/assistant?add=${key}`;
|
||||
});
|
||||
|
||||
function closePane(p) {
|
||||
if (!mounted || !p) return;
|
||||
const at = mounted.panes.indexOf(p);
|
||||
if (at < 0) return;
|
||||
mounted.notice = '';
|
||||
p.chat?.dispose(); p.chat = null; p.el.remove();
|
||||
mounted.panes.splice(at, 1);
|
||||
const next = mounted.panes[Math.min(at, mounted.panes.length - 1)];
|
||||
@@ -387,7 +428,7 @@
|
||||
pick.value = '';
|
||||
if (value.startsWith('new:')) { void openForm(value.slice(4)); return; }
|
||||
const want = parse(`open=${value}`).keys[0];
|
||||
if (want) addPane(want);
|
||||
if (want) { mounted.notice = ''; addPane(want); }
|
||||
});
|
||||
root.addEventListener('click', e => {
|
||||
if (!mounted) return;
|
||||
@@ -411,15 +452,19 @@
|
||||
++mountGen;
|
||||
container.innerHTML = frame();
|
||||
const root = container.querySelector('[data-assistant]');
|
||||
mounted = { root, panes: [], show: null, turn: 0, timer: null, list: null, got: null, o: null, form: null, formTurn: 0 };
|
||||
mounted = { root, panes: [], show: null, turn: 0, timer: null, list: null, got: null, o: null, form: null, formTurn: 0, notice: '' };
|
||||
bind(root);
|
||||
window.addEventListener('resize', fit);
|
||||
document.title = 'Assistant · Mosaic Console';
|
||||
if (navigated) root.querySelector('h1').focus({ preventScroll: true });
|
||||
let want = parse(query);
|
||||
if (!want.named) want = parse(`open=${load() || ''}`);
|
||||
for (const key of want.keys) mounted.panes.push(makePane(key));
|
||||
mounted.show = want.show || want.keys[0] || null;
|
||||
// A thread link followed from another page: a fresh set holds no unsent message, so it always lands.
|
||||
const extra = addOf(query), added = extra && place(extra);
|
||||
if (added) mounted.show = extra;
|
||||
if (added) { els(added).title.setAttribute('tabindex', '-1'); els(added).title.focus({ preventScroll: true }); }
|
||||
else if (navigated) root.querySelector('h1').focus({ preventScroll: true });
|
||||
writeUrl(); paintTabs(); layout();
|
||||
root.querySelector('[data-assist-empty]').hidden = mounted.panes.length > 0;
|
||||
await draw({ navigated: true });
|
||||
@@ -446,6 +491,8 @@
|
||||
mounted.show = want.show || want.keys[0] || null;
|
||||
writeUrl();
|
||||
}
|
||||
const extra = addOf(query);
|
||||
if (extra) add(extra);
|
||||
}
|
||||
infos.clear();
|
||||
void draw({ fresh: true });
|
||||
@@ -454,5 +501,5 @@
|
||||
// A write that changed an organization reads it again.
|
||||
document.addEventListener('mosaic-org-changed', e => { if (mounted && mounted.panes.some(p => p.org === e.detail?.org)) { infos.delete(e.detail.org); void draw(); } });
|
||||
|
||||
window.MosaicAssistant = Object.freeze({ mount, unmount, refresh, hasDraft });
|
||||
window.MosaicAssistant = Object.freeze({ mount, unmount, refresh, hasDraft, add });
|
||||
})();
|
||||
@@ -184,8 +184,12 @@
|
||||
draw();
|
||||
// SP1: every page inside an organization says which one, after the dashboard: Dashboard / Acme / ...
|
||||
const box = $('breadcrumbs'), org = /^#\/orgs\/[^/?]+/.test(location.hash) ? window.MosaicOrg : null;
|
||||
if (!box || !org?.id || box.querySelector('[data-crumb-org]') || /^#\/orgs\/[^/?]+\/organization(?:[/?]|$)/.test(location.hash)) return;
|
||||
box.insertAdjacentHTML('afterbegin', `<a href="#/dashboard">Dashboard</a><span aria-hidden="true">/</span><a href="#/orgs/${encodeURIComponent(org.id)}/organization" data-crumb-org>${escape(org.label)}</a><span aria-hidden="true">/</span>`);
|
||||
if (box && org?.id && !box.querySelector('[data-crumb-org]') && !/^#\/orgs\/[^/?]+\/organization(?:[/?]|$)/.test(location.hash)) box.insertAdjacentHTML('afterbegin', `<a href="#/dashboard">Dashboard</a><span aria-hidden="true">/</span><a href="#/orgs/${encodeURIComponent(org.id)}/organization" data-crumb-org>${escape(org.label)}</a><span aria-hidden="true">/</span>`);
|
||||
// SP7: a trail with nothing above the page only repeats the page's heading, so the heading names it once.
|
||||
if (!box) return;
|
||||
const heading = [...$('main').querySelectorAll('h1')].find(h => h.getClientRects().length)?.textContent.trim();
|
||||
const echo = !box.querySelector('a') && !!heading && box.textContent.trim() === heading;
|
||||
if (box.hidden !== echo) box.hidden = echo;
|
||||
}
|
||||
window.MosaicBreadcrumbs = breadcrumbs;
|
||||
function draw() {
|
||||
|
||||
@@ -317,7 +317,7 @@
|
||||
const messages = s.rows.map((m, i) => `<li class="${m.from_role === 'human' ? 'chat-message-human' : 'chat-message-agent'}" data-message="${esc(m.id)}"><div class="chat-message-meta"><span class="chat-avatar" aria-hidden="true">${txt(String(m.from_role || '?').slice(0, 1).toUpperCase())}</span><strong>${txt(m.from_role === 'human' ? 'You' : m.from_role)}</strong> ${stamp(m.at)}</div>${messageBody(m)}${!redesigned && withRequest.has(m.id) ? mount(m) : ''}</li>${redesigned ? (mountsAfter.get(i) || []).map(origin => `<li class="project-request-item">${mount(origin)}</li>`).join('') : ''}`).join('');
|
||||
const frame = pane ? '' : redesigned ? window.MosaicProjects.header(project, 'conversations', current.threads, t.name, true) : heading(t.name);
|
||||
// A pane leaves out the details: sessions and sharing are unavailable on an organization Console.
|
||||
const resume = threadWrites() ? `<button class="btn" type="button" id="chat-resume"${disabledSession(s.pending || current.channelDisabled)}>Resume assistant</button>` : '';
|
||||
const resume = threadWrites() ? `<button class="btn${pane ? ' pane-resume' : ''}" type="button" id="chat-resume"${disabledSession(s.pending || current.channelDisabled)}>Resume assistant</button>` : '';
|
||||
const details = pane ? `<p class="muted small">Reading never wakes the assistant. An accepted message is not an answer.</p>` : redesigned ? `<dialog class="chat-details project-dialog" id="project-conversation-dialog" aria-labelledby="project-conversation-title"><h2 id="project-conversation-title" tabindex="-1">Conversation details</h2><button class="btn" type="button" data-dialog-close>Close</button>${sessionControls(t)}${sharing(t)}<p>Reading never wakes the assistant. An accepted message is not an answer.</p>${threadWrites() ? `<button class="btn" type="button" id="chat-resume"${disabledSession(s.pending || current.channelDisabled)}>Resume assistant</button>` : ''}</dialog>` : `<details class="chat-details"><summary id="chat-details-toggle">Conversation details</summary>${sessionControls(t)}${sharing(t)}</details><p class="muted">Reading never wakes the assistant. An accepted message is not an answer.</p>${threadWrites() ? `<button class="btn" type="button" id="chat-resume"${disabledSession(s.pending || current.channelDisabled)}>Resume assistant</button>` : ''}`;
|
||||
return `<section class="chat-conversation${redesigned ? ' project-chat' : ''}" data-project="${esc(t.project)}" data-thread="${esc(t.id)}" data-session="${esc(current.session)}">${pane ? '' : `<p>${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}</p>`}${frame}${libraryLine(libraryOf(current, t))}${details}${redesigned ? '<p class="small muted" data-conversation-request-status></p>' : ''}<ol class="chat-log" tabindex="0" aria-label="Thread conversation">${messages || (current.readFailed ? '<li class="muted">Messages unavailable.</li>' : '<li class="muted">No messages yet.</li>')}</ol><div class="chat-paging">${current.readFailed ? '<button type="button" class="btn" data-chat-retry>Read again</button>' : s.more ? '<p>Showing earlier history. Read forward to reach the current conversation.</p><button class="btn" type="button" id="chat-next">Read next messages</button>' : '<p class="muted">Caught up to the last read.</p>'}<button class="btn" type="button" id="chat-new"${s.follow ? ' hidden' : ''}>New messages — go to end</button></div>${threadWrites() ? `<form id="chat-send-form"><label for="chat-body">Message ${txt(t.instance)}</label><textarea id="chat-body" rows="4" placeholder="${redesigned ? `Message ${esc(t.instance)}` : 'Write a message…'}"${disabledSession(s.pending)}>${esc(s.draft)}</textarea><p class="chat-audience">To: ${redesigned ? `<span class="room-chip" aria-label="Recipient ${esc(t.instance)}">${txt(t.instance)}</span>` : `${txt(t.instance)} · Private conversation`}</p>${redesigned ? '<p class="project-composer-hint">Enter adds a new line.</p>' : ''}<button class="btn primary" type="submit"${disabledSession(s.pending || current.channelDisabled)}>Send</button></form>` : ''}<p id="chat-receipt" role="status">${txt(receipt)}</p>${pane ? resume : ''}${redesigned || pane ? '' : '<section class="chat-files"><h2>Files</h2><div data-chat-files>Files unavailable.</div></section><div data-chat-summary></div>'}</section>`;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
.avatar-wrap{position:relative}.avatar-wrap>button{display:flex;align-items:center;gap:8px;min-height:44px;border:0;background:transparent;color:var(--strong);font:inherit;cursor:pointer}.avatar-initial{display:grid;place-items:center;width:34px;height:34px;border-radius:50%;background:var(--action);color:var(--onAction);font-weight:700}.avatar-menu{position:absolute;right:0;top:calc(100% + 8px);z-index:50;width:240px;background:var(--surface);border:1px solid var(--line);border-radius:var(--r);padding:8px;box-shadow:var(--shadow)}.avatar-menu[hidden]{display:none}.avatar-menu a,.avatar-menu button,.avatar-menu label,.avatar-menu #local-sign-out{display:block;width:100%;padding:9px;color:var(--text);font:inherit;text-align:left}.avatar-menu a{text-decoration:none}.avatar-menu button{background:none;border:0;cursor:pointer}.avatar-menu a:hover,.avatar-menu button:hover{background:var(--raised)}.avatar-menu label{font-size:12px;color:var(--muted)}.avatar-menu select{display:block;width:100%;min-height:44px;margin-top:4px}.avatar-menu small{display:block;color:var(--muted)}.avatar-menu #local-sign-out{opacity:.7}
|
||||
#project-context,#project-tree,#board-tree{display:none!important}
|
||||
.s1-section{font-size:14px;gap:12px}.s1-section[aria-current=page]{background:color-mix(in srgb,var(--action) 30%,var(--surface));color:var(--strong)}
|
||||
.breadcrumbs{display:flex;gap:12px;align-items:center;margin:0 0 18px;color:var(--muted);font-size:13px}.breadcrumbs a{color:var(--action);text-decoration:none}.breadcrumbs [aria-current]{color:var(--strong)}
|
||||
.breadcrumbs[hidden]{display:none}.breadcrumbs{display:flex;gap:12px;align-items:center;margin:0 0 18px;color:var(--muted);font-size:13px}.breadcrumbs a{color:var(--action);text-decoration:none}.breadcrumbs [aria-current]{color:var(--strong)}
|
||||
#board-head h1{margin-bottom:0}#board-head p{margin:2px 0}.machine-off #status{display:none}
|
||||
#dashboard-summary,.dashboard-card{border:1px solid var(--line);border-radius:var(--r-lg);background:var(--surface)}#dashboard-summary{margin:20px 0}.kpi-grid{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));padding:18px 0}.kpi{padding:0 18px;border-right:1px solid var(--line);min-width:0}.kpi:last-child{border-right:0}.kpi div{font-size:12px;color:var(--strong);font-weight:700;min-height:36px}.kpi strong{display:block;font-size:28px;line-height:1.2;color:var(--strong)}.kpi span{display:block;color:var(--muted);font-size:12px;margin-top:4px}.kpi-footer{border-top:1px solid var(--line);padding:8px 18px;margin:0;color:var(--muted);font-size:12px}
|
||||
#dashboard-cards{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;margin:20px 0}.dashboard-card{padding:16px}.dashboard-card h2{margin:0 0 10px}.dashboard-card .processing-controls{padding:0;border:0}.dashboard-card .processing-controls strong{display:none}.dashboard-card .processing-controls .btn{min-height:44px}.dashboard-card ul{padding-left:20px}.dashboard-card a{color:var(--action)}
|
||||
|
||||
@@ -202,22 +202,14 @@
|
||||
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();
|
||||
// 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,idPrefix='') {
|
||||
const pid=esc(idPrefix);
|
||||
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="${pid}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 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 && 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="${pid}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="${pid}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>`;
|
||||
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>`;
|
||||
}
|
||||
function overview(data,project,canSend,receipt='') {
|
||||
const active=data.requests.filter(r=>r.state && !ended(r.state)), waiting=active.filter(r=>needs(r.state)), ordered=[...data.requests].sort(requestOrder), first=ordered[0], threads=[...data.threads];
|
||||
@@ -260,9 +252,7 @@
|
||||
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});
|
||||
}
|
||||
// A Side by side pane passes `idPrefix` (pane-a-, pane-b-), so the same project in both panes
|
||||
// draws distinct ids; the full view passes none and keeps its ids.
|
||||
async function mountContent({container,project,threads,section,canSend,restore,roomReceipt,org=null,pane=false,views:paneState=null,idPrefix=''}) {
|
||||
async function mountContent({container,project,threads,section,canSend,restore,roomReceipt,org=null}) {
|
||||
if(!container || !project) return;
|
||||
const generation=epoch, run=(contentRuns.get(container) || 0)+1;
|
||||
contentRuns.set(container,run);
|
||||
@@ -270,11 +260,10 @@
|
||||
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,expandedPrds,idPrefix):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)]);
|
||||
@@ -284,7 +273,7 @@
|
||||
// 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});
|
||||
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().
|
||||
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.
|
||||
@@ -292,12 +281,12 @@
|
||||
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,expandedPrds,idPrefix):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';}); }
|
||||
const north=container.querySelector('[data-north-star]'); if(north && typeof data.north?.content==='string') { const text=section==='overview' && data.north.content.length>500 ? previewCut(data.north.content,500) : data.north.content; north.replaceChildren(window.MosaicMarkdown?.render(text) || document.createTextNode(text)); }
|
||||
const requestSelect='#'+CSS.escape(idPrefix+'project-request-select');
|
||||
const requestSelect='#project-request-select';
|
||||
container.querySelector(requestSelect)?.addEventListener('change',e=>{selectedRequests.set(project.id,e.target.value);draw(e.target.value);container.querySelector(requestSelect)?.focus({preventScroll:true});});
|
||||
};
|
||||
draw(selection);
|
||||
@@ -305,8 +294,8 @@
|
||||
if (restore) {
|
||||
restoreView(container,restore);
|
||||
}
|
||||
const sectionTarget=pane ? null : new URLSearchParams(location.hash.split('?')[1] || '').get('section');
|
||||
if(section==='requirements' && ['prd','north-star'].includes(sectionTarget)) container.querySelector('#'+CSS.escape(idPrefix+'project-'+sectionTarget))?.scrollIntoView({block:'start'});
|
||||
const sectionTarget=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).
|
||||
function previewCut(content,max) {
|
||||
|
||||
@@ -462,7 +462,7 @@ html[data-organizations] .ws-chips{display:flex;flex-wrap:wrap;align-items:cente
|
||||
|
||||
/* Page frame (sample main column). */
|
||||
html[data-organizations] .content:has(.ws){padding-top:18px;padding-inline:24px}
|
||||
body:has([data-org-dashboard]) #breadcrumbs,body:has([data-org-agents]) #breadcrumbs{display:none}
|
||||
body:has([data-org-dashboard]) #breadcrumbs{display:none}
|
||||
html[data-organizations] .ws{font-size:14px;line-height:1.5;color:var(--text);min-width:0}
|
||||
html[data-organizations] .ws h1{margin:0;font-size:28px;font-weight:800;letter-spacing:-.02em;line-height:1.15;color:var(--strong)}
|
||||
html[data-organizations] .ws h1:focus,html[data-organizations] .ws h1:focus-visible{outline:none}
|
||||
@@ -567,7 +567,7 @@ html[data-organizations] .ws-rules .ws-lvl{margin-right:0}
|
||||
html[data-organizations] .ws-scope{margin:-8px 0 14px}
|
||||
|
||||
/* Across organizations (U3): the main dashboard, projects and inbox, samples 02, 04 and 05. */
|
||||
body:has([data-ws-home]) #breadcrumbs,body:has([data-ws-projects]) #breadcrumbs,body:has([data-ws-inbox]) #breadcrumbs{display:none}
|
||||
body:has([data-ws-home]) #breadcrumbs{display:none}
|
||||
html[data-organizations] .ws-edge{box-shadow:inset 3px 0 0 var(--org)}
|
||||
html[data-organizations] .ws-orgref{display:inline-flex;align-items:center;gap:6px;white-space:nowrap;color:var(--strong);font-weight:600}
|
||||
html[data-organizations] .ws-chip{display:inline-flex;align-items:center;gap:5px;font-size:12px;font-weight:600;padding:1px 7px;border-radius:4px;background:var(--raised);color:var(--strong);white-space:nowrap;line-height:1.5}
|
||||
|
||||
@@ -236,21 +236,19 @@
|
||||
const restates = reason => /^the bus is not (running|answering)\.?$/i.test(reason.trim());
|
||||
const downWhy = row => (row && row.state !== 'ok' && typeof row.reason === 'string' && row.reason ? `${row.reason}${row.boot?.at ? `, last start ${when(row.boot.at)}` : ''}` : '');
|
||||
// #1873 SP2: this is the page's only bus banner (the shell's "Bus unavailable" hides on this page), so
|
||||
// it carries the restart action where the Console allows it. A side-by-side pane has no restart.
|
||||
function downBanner(org, row, pane = false) {
|
||||
// it carries the restart action where the Console allows it.
|
||||
function downBanner(org, row) {
|
||||
const stopped = org.busStopped === true;
|
||||
const plain = row && row.state !== 'ok' && typeof row.reason === 'string' && row.reason && restates(row.reason);
|
||||
const why = plain ? '' : downWhy(row);
|
||||
const tail = plain
|
||||
? (row.boot?.at ? ` <span class="why">Last start ${esc(when(row.boot.at))}.</span>` : '')
|
||||
: why ? ` <span class="why">Reason: ${esc(why)}.</span>` : ' <span class="why">No reason was read.</span>';
|
||||
const restart = pane ? '' : window.MosaicConsole?.busButton?.('Restart the bus') || '';
|
||||
const restart = window.MosaicConsole?.busButton?.('Restart the bus') || '';
|
||||
return `<div class="banner err" role="status" data-org-down><div><b>${stopped ? 'The bus is not running.' : 'The bus is not answering.'}</b> Nothing about ${esc(label(org))}'s agents or packs can be read until it ${stopped ? 'starts' : 'answers'}.${tail}</div>${restart ? `<div class="actions">${restart}</div>` : ''}</div>`;
|
||||
}
|
||||
// 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`;
|
||||
async function dashboard(id = window.MosaicOrg.id) {
|
||||
const why = '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;
|
||||
@@ -271,11 +269,12 @@
|
||||
const tracker = business?.business?.vars?.['tracker.kind'];
|
||||
|
||||
// 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 = pane ? '' : window.MosaicConsole?.busButton?.('Restart bus', { stop: true, glyph: icon('restart') }) || '';
|
||||
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 stopTitle = pane ? "Stop the bus from this organization's full view" : "Stopping 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>${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> <button type="button" class="ws-btn" data-bus-stop-off data-focus-key="bus-stop-off" disabled title="${esc(stopTitle)}">Stop bus</button></span>`;
|
||||
// the same control, present and disabled, with the reason. SP7 (#1873): while the bus is down and
|
||||
// the down banner carries the live restart, the header draws none, so the page has one.
|
||||
const restart = window.MosaicConsole?.busButton?.('Restart bus', { stop: true, glyph: icon('restart') }) || '';
|
||||
const offTitle = "Restarting the bus is the owner's trailed action, on a Console started with send and the action trail";
|
||||
const stopTitle = "Stopping the bus is the owner's trailed action, on a Console started with send and the action trail";
|
||||
const bus = restart ? (org.busHealthy ? `<span class="ws-busctl" data-dashboard-bus>${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> <button type="button" class="ws-btn" data-bus-stop-off data-focus-key="bus-stop-off" disabled title="${esc(stopTitle)}">Stop 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()}` : org.busStopped ? `bus stopped, 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>`;
|
||||
@@ -314,7 +313,7 @@
|
||||
return `<article class="ws ws-dash" data-org-dashboard>
|
||||
<nav class="ws-crumb" aria-label="Breadcrumb"><a href="#/dashboard">Dashboard</a><span aria-hidden="true">›</span><span>${name}</span></nav>
|
||||
<div class="ws-orghead ws-o" style="${colours(org)}">${av(org, 'lg')}<h1 tabindex="-1">${name}</h1><div class="ws-right">${bus}${reload}</div>${meta}</div>
|
||||
${org.busHealthy ? '' : downBanner(org, downRow, pane)}
|
||||
${org.busHealthy ? '' : downBanner(org, downRow)}
|
||||
${docrow}
|
||||
<div class="ws-split">
|
||||
<section aria-label="Measures and sessions">
|
||||
@@ -590,7 +589,9 @@
|
||||
const bar = (o, parts, style = '') => `<div class="ws-bar"${style ? ` style="${style}"` : ''} aria-hidden="true">${parts.filter(p => p.n > 0).map(p => `<i class="ws-o" style="${o.style(p.id)};flex:${p.n}"></i>`).join('')}</div>`;
|
||||
const by = (o, parts, fmt = n => n) => `<div class="ws-by">${parts.map(p => `<span>${av(o.meta(p.id))}<span class="ws-sr">${esc(o.name(p.id))}</span>${esc(fmt(p.n))}</span>`).join('')}</div>`;
|
||||
const CLASS_CHIP = { gated: 'gated', 'within-role': 'within', routine: 'routine', 'cross-role': 'cross' };
|
||||
const classChip = c => (c ? `<span class="ws-chip ${CLASS_CHIP[c] || ''}">${esc(c)}</span>` : '');
|
||||
// The one map of decision classes to words: the class chips and the Inbox Class filter both read it.
|
||||
const TYPE_WORD = { gated: 'Gated', 'within-role': 'Within role', 'cross-role': 'Cross-role', routine: 'Routine' };
|
||||
const classChip = c => (c ? `<span class="ws-chip ${CLASS_CHIP[c] || ''}">${esc(TYPE_WORD[c] || c)}</span>` : '');
|
||||
const itemKey = d => `${d.org}:${d.id}`;
|
||||
const roleOf = d => d.session?.role || d.raisedBy || '';
|
||||
const fromWords = (o, d, withOrg = true) => [withOrg ? o.name(d.org) : '', d.project?.name || 'No project', roleOf(d)].filter(Boolean).map(esc).join(' · ');
|
||||
@@ -802,7 +803,6 @@
|
||||
// function that returns the hire, fire and change form bus.js builds for the current organization
|
||||
// (D4, P0-1), shown on the scoped list only. It is called after the read so typing or a submit
|
||||
// result that lands during the read is not painted over with an older draft.
|
||||
const TYPE_WORD = { gated: 'Gated', 'within-role': 'Within role', 'cross-role': 'Cross-role', routine: 'Routine' };
|
||||
const TYPE_RE = /^[a-z][a-z-]{0,31}$/;
|
||||
async function inboxPage(q, opts = {}) {
|
||||
const scope = typeof opts.scope === 'string' && ORG_ID.test(opts.scope) ? opts.scope : null;
|
||||
|
||||
@@ -86,6 +86,7 @@ const panes = `[...document.querySelectorAll('[data-assist-pane]')].map(p => p.d
|
||||
const shown = `[...document.querySelectorAll('[data-assist-pane]')].filter(p => p.getClientRects().length).map(p => p.dataset.assistPane)`;
|
||||
const query = `new URLSearchParams(location.hash.split('?')[1] || '').get('open')`;
|
||||
const ready = sel => `!!${PANE(sel)}?.querySelector('.chat-log li') && !!${PANE(sel)}?.querySelector('[data-ws-orgchip]')`;
|
||||
const trail = '(document.getElementById("breadcrumbs").getClientRects().length ? document.getElementById("breadcrumbs").innerText.replace(/\\s+/g, " ").trim() : null)';
|
||||
const SENT = '/Accepted message|Message shown in this conversation/';
|
||||
const pick = (b, value) => b.evaluate(`(() => { const s = document.querySelector('[data-assist-open]'); s.value = ${JSON.stringify(value)}; if (s.value !== ${JSON.stringify(value)}) throw Error('no option ' + ${JSON.stringify(value)}); s.dispatchEvent(new Event('change', { bubbles: true })); })()`);
|
||||
|
||||
@@ -96,6 +97,8 @@ test('the sidebar Assistant opens one system page, and two threads from two orga
|
||||
await b.evaluate(`document.querySelector('#sections a[href="#/assistant"]').click()`);
|
||||
await wait(`location.hash.startsWith('#/assistant') && document.querySelector('#bus-view h1')?.textContent === 'Assistant'`);
|
||||
assert.equal(await b.evaluate('document.querySelector(\'#sections a[href="#/assistant"]\').getAttribute("aria-current")'), 'page');
|
||||
// SP7: the page name shows once, as the heading.
|
||||
assert.equal(await b.evaluate(trail), null);
|
||||
await wait(`!!document.querySelector('[data-assist-open] optgroup[label^="Beta"] option[value="beta:${T.b1}"]')`);
|
||||
await pick(b, `alpha:${T.a1}`);
|
||||
await pick(b, `beta:${T.b1}`);
|
||||
@@ -279,3 +282,69 @@ test('"Open thread" starts a new thread in a chosen organization through that or
|
||||
assert.deepEqual(state.created.map(({ org, project, name, instance }) => ({ org, project, name, instance })), [{ org: 'beta', project: P.beta, name: 'Rate limits', instance: 'assistant' }]);
|
||||
assert.equal(await b.evaluate(query), `beta:${NEW}`);
|
||||
});
|
||||
|
||||
// SP7: a thread link anywhere opens that thread as a pane here; a typed per-organization address
|
||||
// still opens that organization's own chat view.
|
||||
const follow = (b, href) => b.evaluate(`(() => { const a = document.createElement('a'); a.href = ${JSON.stringify(href)}; a.textContent = 'thread'; document.body.append(a); a.click(); a.remove(); })()`);
|
||||
const type = (b, sel, text) => b.evaluate(`(() => { const el = ${PANE(sel)}.querySelector('form textarea'); el.value = ${JSON.stringify(text)}; el.dispatchEvent(new Event('input', { bubbles: true })); })()`);
|
||||
const field = sel => `${PANE(sel)}?.querySelector('form textarea')`;
|
||||
|
||||
test('SP7: a thread link on a project page opens the thread as a pane on the Assistant page, added to the open set', async t => {
|
||||
const { b, wait } = await open(t, { hash: `#/assistant?open=beta:${T.b1}` });
|
||||
await wait(ready(`beta:${T.b1}`));
|
||||
await b.evaluate(`location.hash = '#/orgs/alpha/projects/${P.alpha}'`);
|
||||
await wait(`!!document.querySelector('#bus-view a[href$="/threads/${T.a1}"]')`);
|
||||
await b.evaluate(`document.querySelector('#bus-view a[href$="/threads/${T.a1}"]').click()`);
|
||||
await wait(`location.hash.startsWith('#/assistant') && ${ready(`alpha:${T.a1}`)}`);
|
||||
assert.deepEqual(await b.evaluate(panes), [`beta:${T.b1}`, `alpha:${T.a1}`]);
|
||||
assert.equal(await b.evaluate(query), `beta:${T.b1},alpha:${T.a1}`);
|
||||
// A per-organization chat address typed or pasted still opens that organization's chat view.
|
||||
await b.evaluate(`location.hash = '#/orgs/beta/projects/threads/${T.b1}'`);
|
||||
await wait(`!document.querySelector('[data-assistant]') && /Beta tokens rotate nightly/.test(document.getElementById('bus-view').innerText)`);
|
||||
assert.equal(await b.evaluate('location.hash'), `#/orgs/beta/projects/threads/${T.b1}`);
|
||||
});
|
||||
|
||||
test('SP7: with three open, a fourth thread replaces the first pane with no unsent message; when all three hold one, nothing is replaced and the page says why', async t => {
|
||||
const state = { down: new Set(), sent: [], created: [] };
|
||||
const { b, wait } = await open(t, { hash: `#/assistant?open=alpha:${T.a1},beta:${T.b1}`, state });
|
||||
await wait(`${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)} && !${field(`alpha:${T.a1}`)}.disabled`);
|
||||
await type(b, `alpha:${T.a1}`, 'alpha draft');
|
||||
// On the Assistant page a thread link adds a pane in place: the typed message stays.
|
||||
await follow(b, `#/orgs/alpha/projects/threads/${T.a2}`);
|
||||
await wait(ready(`alpha:${T.a2}`));
|
||||
assert.deepEqual(await b.evaluate(panes), [`alpha:${T.a1}`, `beta:${T.b1}`, `alpha:${T.a2}`]);
|
||||
assert.equal(await b.evaluate(`${field(`alpha:${T.a1}`)}.value`), 'alpha draft');
|
||||
// A fourth: Alpha's first pane holds a message, so Beta's pane, the first without one, gives way.
|
||||
await follow(b, `#/orgs/home/projects/threads/${T.h1}`);
|
||||
await wait(ready(`home:${T.h1}`));
|
||||
assert.deepEqual(await b.evaluate(panes), [`alpha:${T.a1}`, `home:${T.h1}`, `alpha:${T.a2}`]);
|
||||
assert.equal(await b.evaluate(query), `alpha:${T.a1},home:${T.h1},alpha:${T.a2}`);
|
||||
assert.equal(await b.evaluate(`${field(`alpha:${T.a1}`)}.value`), 'alpha draft');
|
||||
// All three hold a message: nothing is replaced and the page says why.
|
||||
await wait(`!${field(`home:${T.h1}`)}.disabled && !${field(`alpha:${T.a2}`)}.disabled`);
|
||||
await type(b, `home:${T.h1}`, 'home draft');
|
||||
await type(b, `alpha:${T.a2}`, 'kickoff draft');
|
||||
await follow(b, `#/orgs/beta/projects/threads/${T.b1}`);
|
||||
await wait(`/not opened/.test(document.querySelector('[data-assist-note]').textContent)`);
|
||||
assert.match(await b.evaluate(`document.querySelector('[data-assist-note]').textContent`), /all 3 panes hold an unsent message/);
|
||||
assert.deepEqual(await b.evaluate(panes), [`alpha:${T.a1}`, `home:${T.h1}`, `alpha:${T.a2}`]);
|
||||
assert.deepEqual(await b.evaluate(`[${field(`alpha:${T.a1}`)}.value, ${field(`home:${T.h1}`)}.value, ${field(`alpha:${T.a2}`)}.value]`), ['alpha draft', 'home draft', 'kickoff draft']);
|
||||
assert.equal(await b.evaluate(query), `alpha:${T.a1},home:${T.h1},alpha:${T.a2}`);
|
||||
// Closing a pane clears the reason; the thread then opens.
|
||||
await b.evaluate(`${PANE(`home:${T.h1}`)}.querySelector('button[data-assist-close]').click()`);
|
||||
await wait(`!/not opened/.test(document.querySelector('[data-assist-note]').textContent)`);
|
||||
await follow(b, `#/orgs/beta/projects/threads/${T.b1}`);
|
||||
await wait(ready(`beta:${T.b1}`));
|
||||
assert.deepEqual(await b.evaluate(panes), [`alpha:${T.a1}`, `alpha:${T.a2}`, `beta:${T.b1}`]);
|
||||
assert.deepEqual(state.sent, []);
|
||||
});
|
||||
|
||||
test('SP7: each pane names its own organization\'s Messages page, and the bar no longer names the organization opened last', async t => {
|
||||
const { b, wait } = await open(t, { hash: '#/orgs/beta/tasks' });
|
||||
await wait(`window.MosaicOrg?.id === 'beta'`);
|
||||
await b.evaluate(`location.hash = '#/assistant?open=alpha:${T.a1},beta:${T.b1}'`);
|
||||
await wait(`${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)}`);
|
||||
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-assist-pane]')].map(p => { const a = p.querySelector('.assist-pane-h a[data-assist-pane-messages]'); return a && [a.getAttribute('href'), a.getAttribute('aria-label'), a.textContent.trim()]; })`),
|
||||
[['#/orgs/alpha/messages', 'Messages in Alpha', 'Messages'], ['#/orgs/beta/messages', 'Messages in Beta', 'Messages']]);
|
||||
assert.doesNotMatch(await b.evaluate(`document.querySelector('.assist-bar').innerText`), /Messages/);
|
||||
});
|
||||
@@ -371,6 +371,10 @@ test('a stopped organization\'s page shows one bus banner, with the restart acti
|
||||
assert.match(shown[0], /The bus is not running\. Nothing about Gamma Home's agents or packs can be read until it starts\./);
|
||||
assert.match(shown[0], /Restart the bus/);
|
||||
assert.equal(await b.evaluate('document.getElementById("bus-down").hidden'), true);
|
||||
// SP7 item 7: one restart control on the page, the banner's; the header draws no second one.
|
||||
const restarts = 'Array.from(document.querySelectorAll("[data-bus-restart], [data-bus-restart-off]")).filter(e => e.getClientRects().length).map(e => e.closest("[data-org-down]") ? "banner" : "header")';
|
||||
assert.deepEqual(await b.evaluate(restarts), ['banner']);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-org-dashboard] .ws-orghead [data-dashboard-bus]")'), null);
|
||||
// The organization's other pages keep the shell's banner, with its restart.
|
||||
await b.evaluate('location.hash = "#/orgs/gamma/agents"');
|
||||
await wait('!document.getElementById("bus-down").hidden && /Bus unavailable/.test(document.getElementById("bus-down").textContent)');
|
||||
|
||||
@@ -102,6 +102,7 @@ async function setup(t, { viewport = [1440, 900], hash = '#/agents', empty = fal
|
||||
}
|
||||
const rows = 'JSON.stringify([...document.querySelectorAll("[data-ws-agent]")].map(r => r.dataset.wsAgent))';
|
||||
const are = list => `${rows} === ${JSON.stringify(JSON.stringify(list))}`;
|
||||
const trail = '(document.getElementById("breadcrumbs").getClientRects().length ? document.getElementById("breadcrumbs").innerText.replace(/\\s+/g, " ").trim() : null)';
|
||||
const ALL = ['acme:chief', 'acme:coder', 'beta:pm', 'beta:research-2'];
|
||||
|
||||
test('#/agents lists both organizations\' agents in one table, each row with its organization chip', async t => {
|
||||
@@ -111,6 +112,8 @@ test('#/agents lists both organizations\' agents in one table, each row with its
|
||||
assert.equal(await b.evaluate('location.hash.split("?")[0]'), '#/agents');
|
||||
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]")?.getAttribute("href")'), '#/agents');
|
||||
assert.equal(await text('#bus-view h1'), 'Agents');
|
||||
// SP7: the page name shows once, as the heading; a trail with nothing above it is not drawn.
|
||||
assert.equal(await b.evaluate(trail), null);
|
||||
assert.equal(await text('[data-ws-agent="acme:coder"] [data-ws-orgchip]'), 'Acme Studio');
|
||||
assert.equal(await text('[data-ws-agent="beta:pm"] [data-ws-orgchip]'), 'Beta Works');
|
||||
// State from the bus, and what the agent works on: its task, with the task's project chip.
|
||||
@@ -206,6 +209,8 @@ test('#/orgs/<id>/agents shows that organization\'s rows only, with a link to ev
|
||||
assert.match(await text('[data-ws-agents] .sub'), /Beta Works/);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-ws-agents] a[href=\'#/agents\']") !== null'), true);
|
||||
assert.equal(await b.evaluate('location.hash'), '#/orgs/beta/agents');
|
||||
// SP7: a drill-in keeps its trail back to the dashboard and the organization.
|
||||
await wait(`${trail} === 'Dashboard / Beta Works / Agents'`);
|
||||
// The organization's own levels grid and held roles still follow, under the one page heading.
|
||||
await wait('document.querySelector("[data-org-agents]")');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view h1").length'), 1);
|
||||
|
||||
@@ -99,6 +99,7 @@ async function setup(t, { viewport = [1440, 900], hash = '#/inbox', empty = fals
|
||||
await b.navigate(`${base}/${hash}`);
|
||||
return { b, base, wait: waitFor(b), text: sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`) };
|
||||
}
|
||||
const trail = '(document.getElementById("breadcrumbs").getClientRects().length ? document.getElementById("breadcrumbs").innerText.replace(/\\s+/g, " ").trim() : null)';
|
||||
const items = 'JSON.stringify([...document.querySelectorAll("[data-ws-item]")].map(r => r.dataset.wsItem))';
|
||||
|
||||
test('#/inbox lists both organizations in one list, newest first, each row with its organization and project chips', async t => {
|
||||
@@ -108,11 +109,15 @@ test('#/inbox lists both organizations in one list, newest first, each row with
|
||||
assert.equal(await b.evaluate('location.hash.split("?")[0]'), '#/inbox');
|
||||
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]")?.getAttribute("href")'), '#/inbox');
|
||||
assert.equal(await text('#bus-view h1'), 'Inbox');
|
||||
// SP7: the page name shows once, as the heading.
|
||||
assert.equal(await b.evaluate(trail), null);
|
||||
// Organization chips on every row; the project chip only where the item has a project.
|
||||
assert.equal(await text(`[data-ws-item="acme:${ACME_DECISION}"] [data-ws-orgchip]`), 'Acme Studio');
|
||||
assert.equal(await text(`[data-ws-item="beta:${BETA_DECISION}"] [data-ws-orgchip]`), 'Beta Works');
|
||||
assert.equal(await text(`[data-ws-item="acme:${ACME_DECISION}"] [data-ws-projchip]`), 'Website');
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-ws-item="beta:${BETA_DECISION}"] [data-ws-projchip]')`), null);
|
||||
// SP7: the class chip says the decision class in words, from the one map of class words.
|
||||
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-ws-item]')].map(r => r.querySelector('td.hide-m .ws-chip')?.textContent)`), ['Within role', 'Gated']);
|
||||
// The stopped organization is named at the top, before the list, with its reason.
|
||||
const note = await text('[data-ws-unread]');
|
||||
assert.match(note, /Gamma Home/);
|
||||
@@ -157,6 +162,7 @@ test('answering beta\'s row from the list while acme was opened last reaches bet
|
||||
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'acme');
|
||||
await b.evaluate(`document.querySelector('[data-ws-select="beta:${BETA_DECISION}"]').click()`);
|
||||
await wait(`document.querySelector("[data-ws-insp]")?.dataset.wsInsp === "beta:${BETA_DECISION}" && document.querySelector("#decide-section [data-decide]")`);
|
||||
assert.equal(await text('[data-ws-insp] header .ws-chip'), 'Within role');
|
||||
// The item also opens in its own organization.
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-ws-insp] [data-ws-open]")?.getAttribute("href")'), `#/orgs/beta/inbox/${BETA_DECISION}`);
|
||||
await b.evaluate(`window.__answers.push({ body: { id: '${BETA_DECISION}', action: 'answer', choice: 'high', text: 'High', confirmation: 'c-1', kind: 'decision' } }, { body: { id: '${BETA_DECISION}', op: 'resolved', choice: 'high' } })`);
|
||||
@@ -183,6 +189,8 @@ test('#/orgs/<id>/inbox is the same list filtered to that organization', async t
|
||||
await wait(`${items} === ${JSON.stringify(JSON.stringify([`beta:${BETA_DECISION}`]))}`);
|
||||
assert.match(await text('[data-ws-inbox] .sub'), /Beta Works/);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-ws-inbox] a[href=\'#/inbox\']") !== null'), true);
|
||||
// SP7: a drill-in keeps its trail.
|
||||
await wait(`${trail} === 'Dashboard / Beta Works / Inbox'`);
|
||||
// Selecting a row keeps the organization address.
|
||||
await b.evaluate(`document.querySelector('[data-ws-select="beta:${BETA_DECISION}"]').click()`);
|
||||
await wait(`location.hash.startsWith("#/orgs/beta/inbox?") && document.querySelector("[data-ws-insp]")?.dataset.wsInsp === "beta:${BETA_DECISION}"`);
|
||||
|
||||
@@ -92,6 +92,7 @@ async function setup(t, { viewport = [1440, 900], hash = '#/tasks', empty = fals
|
||||
await b.navigate(`${base}/${hash}`);
|
||||
return { b, base, orgs, wait: waitFor(b), text: sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`) };
|
||||
}
|
||||
const trail = '(document.getElementById("breadcrumbs").getClientRects().length ? document.getElementById("breadcrumbs").innerText.replace(/\\s+/g, " ").trim() : null)';
|
||||
const cards = 'JSON.stringify([...document.querySelectorAll("[data-tasks-board] [data-tb-task]")].map(e => e.dataset.tbTask).sort())';
|
||||
const ALL = JSON.stringify(JSON.stringify(['acme|task:1', 'beta|task:2', 'beta|task:3']));
|
||||
const BETA = JSON.stringify(JSON.stringify(['beta|task:2', 'beta|task:3']));
|
||||
@@ -103,6 +104,8 @@ test('#/tasks shows both organizations\' tasks on one board, each card with its
|
||||
assert.equal(await b.evaluate('location.hash'), '#/tasks');
|
||||
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]")?.getAttribute("href")'), '#/tasks');
|
||||
assert.equal(await text('#bus-view h1'), 'Tasks');
|
||||
// SP7: the page name shows once, as the heading.
|
||||
assert.equal(await b.evaluate(trail), null);
|
||||
// The per-organization page's columns.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".tb-col h3")].map(h => h.textContent)'), ['To do', 'In progress', 'Review', 'Blocked', 'Done']);
|
||||
// One chip names the organization, with its avatar in place of the dot.
|
||||
@@ -172,6 +175,8 @@ test('#/orgs/beta/tasks shows beta\'s tasks only, with a link to every organizat
|
||||
await wait(`${cards} === ${BETA}`);
|
||||
assert.equal(await b.evaluate('location.hash'), '#/orgs/beta/tasks');
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-tasks-board]").dataset.tbScope'), 'beta');
|
||||
// SP7: a drill-in keeps its trail.
|
||||
await wait(`${trail} === 'Dashboard / Beta Works / Tasks'`);
|
||||
// No filter is on, so no "match the filters" line: the scope is not a filter.
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-tasks-board] .tb-count")'), null);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-tb-org]")'), null);
|
||||
|
||||
Reference in new issue
Block a user