fix(webui): a system page names itself once, thread links open as Assistant panes, one restart control on a stopped organization (#1873) #1971
No files matched your search
@@ -14,6 +14,7 @@ html[data-organizations] .assist-open:disabled{color:var(--muted);cursor:not-all
|
||||
html[data-organizations] .assist [data-assist-unread]:empty,html[data-organizations] .assist [data-assist-note]:empty{display:none}
|
||||
html[data-organizations] .assist [data-assist-unread] .note{margin:0}
|
||||
html[data-organizations] .assist [data-assist-note]{margin:0;font-size:13px;color:var(--muted)}
|
||||
html[data-organizations] .assist [data-assist-note][data-refused]{color:var(--warning);font-weight:600}
|
||||
|
||||
html[data-organizations] .assist-new{border:1px solid var(--line);border-radius:var(--r);background:var(--surface);padding:12px 14px;display:grid;gap:10px}
|
||||
html[data-organizations] .assist-new[hidden]{display:none}
|
||||
@@ -35,12 +36,21 @@ html[data-organizations] .assist-panes{display:grid;grid-template-columns:repeat
|
||||
html[data-organizations] .assist-panes[hidden]{display:none}
|
||||
html[data-organizations] .assist-pane{display:flex;flex-direction:column;min-width:0;min-height:0;border:1px solid var(--line);border-radius:var(--r);background:var(--surface);overflow:hidden}
|
||||
html[data-organizations] .assist-pane-h{display:flex;align-items:center;gap:8px;padding:10px 12px;border-bottom:1px solid var(--line);box-shadow:inset 0 3px 0 var(--org,var(--line));min-width:0;flex-wrap:wrap}
|
||||
html[data-organizations] .assist-pane-h h2{margin:0;font-size:15px;font-weight:700;color:var(--strong,var(--text));overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1 1 120px}
|
||||
html[data-organizations] .assist-pane-h h2{margin:0;font-size:15px;font-weight:700;color:var(--strong,var(--text));overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:5em;flex:1 1 0}
|
||||
html[data-organizations] .assist-pane-h h2:focus{outline:none}
|
||||
html[data-organizations] .assist-chips{display:inline-flex;gap:6px;flex-wrap:wrap;min-width:0}
|
||||
html[data-organizations] .assist-chips{display:inline-flex;gap:6px;flex-wrap:wrap;min-width:0;order:1;flex:1 0 100%}
|
||||
html[data-organizations] .assist-chips:empty{display:none}
|
||||
/* SP7: a pane's tools (Full view, Messages, close) stay together on the title's row; the chips take the row under it.
|
||||
A pane too narrow for them wraps the tools, never pushes one past the pane's edge. */
|
||||
html[data-organizations] .assist-pane-tools{display:inline-flex;align-items:center;justify-content:flex-end;gap:2px;flex:0 1 auto;flex-wrap:wrap;max-width:100%;margin-left:auto}
|
||||
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, the
|
||||
// sidebar's thread links) opens that thread as a pane here, added to the open set; #/assistant?add=<org>:<thread>
|
||||
// carries it across the page change. A link marked data-full-view (a pane's Full view, a requirement's
|
||||
// "Open conversation") and a per-organization chat address typed or pasted open 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,7 +132,11 @@
|
||||
<div class="assist-pane-h">
|
||||
<h2 id="assist-p${n}-title" data-assist-title>Reading…</h2>
|
||||
<span class="assist-chips" data-assist-chips></span>
|
||||
<button type="button" class="pane-tool" data-assist-close aria-label="Close thread">${CLOSE}</button>
|
||||
<span class="assist-pane-tools">
|
||||
<a class="pane-tool assist-msgs" data-assist-pane-full data-full-view href="#/orgs/${enc(p.org)}/projects/threads/${enc(p.id)}">Full view</a>
|
||||
<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>
|
||||
</span>
|
||||
</div>
|
||||
<p class="assist-line" data-assist-line role="status" hidden></p>
|
||||
<div class="assist-body" data-assist-body></div>
|
||||
@@ -136,7 +144,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]'), full: p.el.querySelector('[data-assist-pane-full]'), 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
|
||||
@@ -183,22 +191,21 @@
|
||||
if (paused()) { mounted.timer = setTimeout(tick, POLL); return; }
|
||||
void draw();
|
||||
}
|
||||
// The line under the lead: how many panes are open, or why a thread was not opened (drawn as a warning).
|
||||
function paintNote() {
|
||||
const el = mounted.root.querySelector('[data-assist-note]');
|
||||
setText(el, mounted.notice || (mounted.panes.length ? `${mounted.panes.length} open, up to ${MAX}` : ''));
|
||||
el?.toggleAttribute('data-refused', !!mounted.notice);
|
||||
}
|
||||
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}` : '');
|
||||
paintNote();
|
||||
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 +218,8 @@
|
||||
p.title = title;
|
||||
setText(e.title, title);
|
||||
e.close.setAttribute('aria-label', `Close ${title}, ${name}`);
|
||||
e.full.setAttribute('aria-label', `Full view of ${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 +260,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 +318,53 @@
|
||||
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; paintNote(); 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]');
|
||||
// A link marked data-full-view (a pane's Full view, a requirement's "Open conversation") goes to the
|
||||
// organization's own thread view, which has Request details, sessions and sharing.
|
||||
if (!a || a.hasAttribute('download') || a.hasAttribute('data-full-view') || (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 +440,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 +464,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 +503,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 +513,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,30 +202,22 @@
|
||||
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 data-full-view 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 data-full-view 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];
|
||||
if(first) threads.sort((a,b)=>Number(b.id===first.thread.id)-Number(a.id===first.thread.id));
|
||||
const f=filesData(data), filesKnown=!data.failed && !!f.docs && data.requests.every(r=>r.workspace?.available===true && Array.isArray(r.workspace.entries) && r.state), count=filesKnown?f.reviewed.length+f.drafts.length+f.docs.length:'–';
|
||||
const n=typeof data.north?.content==='string'?data.north.content:null, candidate=ordered.find(r=>r.draft)?.draft;
|
||||
const metrics=[['Requests in progress',data.failed?'–':active.length,data.failed?'Not available yet':active[0]?label(active[0].state):'No active requests',first?conversationLink(first.thread):path(project.id,'conversations')],['Needs your input',data.failed?'–':waiting.length,data.failed?'Not available yet':waiting.length?label(waiting[0].state):'Nothing waiting',waiting[0]?conversationLink(waiting[0].thread):path(project.id,'conversations')],['Conversations',threads.length,'Project conversations',path(project.id,'conversations')],['Files',count,filesKnown?`${f.reviewed.length} reviewed · ${f.drafts.length} draft · ${f.docs.length} docs`:data.recordsMissing?'No project records yet':'Not available yet',path(project.id,'files')]];
|
||||
return `${waiting.length?`<div class="project-panel project-decision" role="status">${waiting.length} ${waiting.length===1?'decision needs':'decisions need'} your input. <a class="btn primary" href="${conversationLink(waiting[0].thread)}">Review decision</a></div>`:''}<div class="project-metrics">${metrics.map(([title,value,caption,href])=>`<a href="${href}"><span>${title}</span><strong>${value}</strong><small>${clean(caption)}</small></a>`).join('')}<p>Updated just now · Showing requests from recent messages.</p></div><div class="project-columns"><section><div class="project-section-title"><h2>Current work</h2>${canSend&&!project.archived?`<a class="btn" href="${path(project.id,'conversations')}?new=1">${plusIcon}New conversation</a>`:''}</div>${threads.length?threads.map((t,i)=>i===0?card(t,workFor(data,t),data.failed):`<a class="project-row" href="${conversationLink(t)}"><strong>${clean(t.name)}</strong><small>With ${clean(t.instance)} · ${workFor(data,t) ? label(workFor(data,t).state) : data.failed ? 'Status unavailable' : 'No active request'}</small>${workFor(data,t)?chip(workFor(data,t).state):''}</a>`).join(''):'<p>No conversations yet. Start one to begin work.</p>'}<h2>Tasks <span class="project-count">${taskRefs(data).length}</span></h2><div class="project-panel">${taskRefs(data).length?taskRefs(data).slice(0,2).map(ref=>`<div>${taskLink(ref,data)}</div>`).join(''):data.failed?'<p>Request tasks unavailable. Refresh to try again.</p>':'<p>No request tasks available.</p>'}<a href="${path(project.id,'tasks')}">View all project tasks</a></div></section><aside class="project-panel"><h2>Project direction</h2><h3>North star</h3>${n!==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>`:`<p>${data.northRecordsMissing?window.MosaicRecords.markup(project.repo):data.northMissing?'No north star yet.':project.repo?'North star unavailable. Docs could not read this project’s repository.':"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>${candidate?'PRD '+clean(candidate.candidate.prd_version):'PRD'} ${candidate?.snapshot.id?'<span class="project-chip success">Approved</span>':''}</h3><p>${candidate?(candidate.snapshot.id?'Approved snapshot '+clean(candidate.snapshot.id):'Draft'):(data.failed || data.requests.some(x=>x.materialFailed)?'PRD unavailable.':'No current PRD available.')}</p>${candidate && data.requests.some(x=>x.materialFailed)?'<p role="status">Some request PRDs could not be read. Refresh to try again.</p>':''}<a href="${path(project.id,'requirements')}">Open PRD and requirements</a><h3>Files ${filesKnown?`<span class="project-count">${count}</span>`:''}</h3>${[...f.reviewed.map(x=>({name:x.file.name,reviewed:true})),...f.drafts.map(x=>({name:x.name,reviewed:false}))].slice(0,3).map(file=>file.reviewed?`<p><a href="${path(project.id,'files')}"><code>${clean(file.name)}</code> · Reviewed</a></p>`:`<p><code>${clean(file.name)}</code> · Draft</p>`).join('')||(data.failed?'<p>Request files unavailable.</p>':'<p>No request files available.</p>')}<a href="${path(project.id,'files')}">All files</a><h3>Project facts</h3><div class="project-provenance-row"><span>Repository</span><span>${project.repo?clean(project.repo):'Not linked'}</span></div>${roomControls(project,data.room,canSend,receipt)}<div class="project-provenance-row"><span>Mode</span><span>${first?.state?.mode?clean(modeLabel(first.state.mode)):data.failed?'Unavailable':'No active request'}</span></div></aside></div>`;
|
||||
const metrics=[['Requests in progress',data.failed?'–':active.length,data.failed?'Not available yet':active[0]?label(active[0].state):'No active requests',first?conversationLink(first.thread):path(project.id,'conversations'),!!first],['Needs your input',data.failed?'–':waiting.length,data.failed?'Not available yet':waiting.length?label(waiting[0].state):'Nothing waiting',waiting[0]?conversationLink(waiting[0].thread):path(project.id,'conversations'),!!waiting[0]],['Conversations',threads.length,'Project conversations',path(project.id,'conversations')],['Files',count,filesKnown?`${f.reviewed.length} reviewed · ${f.drafts.length} draft · ${f.docs.length} docs`:data.recordsMissing?'No project records yet':'Not available yet',path(project.id,'files')]];
|
||||
return `${waiting.length?`<div class="project-panel project-decision" role="status">${waiting.length} ${waiting.length===1?'decision needs':'decisions need'} your input. <a class="btn primary" data-full-view href="${conversationLink(waiting[0].thread)}">Review decision</a></div>`:''}<div class="project-metrics">${metrics.map(([title,value,caption,href,full])=>`<a${full?' data-full-view':''} href="${href}"><span>${title}</span><strong>${value}</strong><small>${clean(caption)}</small></a>`).join('')}<p>Updated just now · Showing requests from recent messages.</p></div><div class="project-columns"><section><div class="project-section-title"><h2>Current work</h2>${canSend&&!project.archived?`<a class="btn" href="${path(project.id,'conversations')}?new=1">${plusIcon}New conversation</a>`:''}</div>${threads.length?threads.map((t,i)=>i===0?card(t,workFor(data,t),data.failed):`<a class="project-row" href="${conversationLink(t)}"><strong>${clean(t.name)}</strong><small>With ${clean(t.instance)} · ${workFor(data,t) ? label(workFor(data,t).state) : data.failed ? 'Status unavailable' : 'No active request'}</small>${workFor(data,t)?chip(workFor(data,t).state):''}</a>`).join(''):'<p>No conversations yet. Start one to begin work.</p>'}<h2>Tasks <span class="project-count">${taskRefs(data).length}</span></h2><div class="project-panel">${taskRefs(data).length?taskRefs(data).slice(0,2).map(ref=>`<div>${taskLink(ref,data)}</div>`).join(''):data.failed?'<p>Request tasks unavailable. Refresh to try again.</p>':'<p>No request tasks available.</p>'}<a href="${path(project.id,'tasks')}">View all project tasks</a></div></section><aside class="project-panel"><h2>Project direction</h2><h3>North star</h3>${n!==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>`:`<p>${data.northRecordsMissing?window.MosaicRecords.markup(project.repo):data.northMissing?'No north star yet.':project.repo?'North star unavailable. Docs could not read this project’s repository.':"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>${candidate?'PRD '+clean(candidate.candidate.prd_version):'PRD'} ${candidate?.snapshot.id?'<span class="project-chip success">Approved</span>':''}</h3><p>${candidate?(candidate.snapshot.id?'Approved snapshot '+clean(candidate.snapshot.id):'Draft'):(data.failed || data.requests.some(x=>x.materialFailed)?'PRD unavailable.':'No current PRD available.')}</p>${candidate && data.requests.some(x=>x.materialFailed)?'<p role="status">Some request PRDs could not be read. Refresh to try again.</p>':''}<a href="${path(project.id,'requirements')}">Open PRD and requirements</a><h3>Files ${filesKnown?`<span class="project-count">${count}</span>`:''}</h3>${[...f.reviewed.map(x=>({name:x.file.name,reviewed:true})),...f.drafts.map(x=>({name:x.name,reviewed:false}))].slice(0,3).map(file=>file.reviewed?`<p><a href="${path(project.id,'files')}"><code>${clean(file.name)}</code> · Reviewed</a></p>`:`<p><code>${clean(file.name)}</code> · Draft</p>`).join('')||(data.failed?'<p>Request files unavailable.</p>':'<p>No request files available.</p>')}<a href="${path(project.id,'files')}">All files</a><h3>Project facts</h3><div class="project-provenance-row"><span>Repository</span><span>${project.repo?clean(project.repo):'Not linked'}</span></div>${roomControls(project,data.room,canSend,receipt)}<div class="project-provenance-row"><span>Mode</span><span>${first?.state?.mode?clean(modeLabel(first.state.mode)):data.failed?'Unavailable':'No active request'}</span></div></aside></div>`;
|
||||
}
|
||||
function tasksView(data) {
|
||||
const refs=taskRefs(data);
|
||||
@@ -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,120 @@ 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.equal(await b.evaluate(`document.querySelector('[data-assist-note]').hasAttribute('data-refused')`), true);
|
||||
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)`);
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-assist-note]').hasAttribute('data-refused')`), false);
|
||||
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/);
|
||||
});
|
||||
|
||||
// SP7 review: a pane has no Request details, sessions or sharing, so each pane links its thread's full
|
||||
// view, and a link marked data-full-view is not turned into a pane.
|
||||
test('SP7: each pane links its thread\'s full view, and one plain click reaches it', async t => {
|
||||
const { b, wait } = await open(t, { hash: `#/assistant?open=alpha:${T.a1},beta:${T.b1}` });
|
||||
await wait(`${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)}`);
|
||||
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-assist-pane]')].map(p => { const a = p.querySelector('.assist-pane-h a[data-assist-pane-full]'); return a && [a.getAttribute('href'), a.textContent.trim(), a.hasAttribute('data-full-view')]; })`),
|
||||
[[`#/orgs/alpha/projects/threads/${T.a1}`, 'Full view', true], [`#/orgs/beta/projects/threads/${T.b1}`, 'Full view', true]]);
|
||||
// Each pane's Full view names its thread and organization, so a screen reader can tell three apart.
|
||||
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-assist-pane-full]')].map(a => a.getAttribute('aria-label'))`),
|
||||
['Full view of Launch email, Alpha', 'Full view of Gateway auth, Beta']);
|
||||
await b.evaluate(`${PANE(`beta:${T.b1}`)}.querySelector('a[data-assist-pane-full]').click()`);
|
||||
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: a link marked as the full view stays in the organization\'s own view, from any page', async t => {
|
||||
const { b, wait } = await open(t, { hash: `#/assistant?open=alpha:${T.a1}` });
|
||||
await wait(ready(`alpha:${T.a1}`));
|
||||
// On the Assistant page itself: no pane is added, the browser follows the link.
|
||||
await b.evaluate(`(() => { const a = document.createElement('a'); a.href = '#/orgs/beta/projects/threads/${T.b1}'; a.setAttribute('data-full-view', ''); a.textContent = 'thread'; document.body.append(a); a.click(); a.remove(); })()`);
|
||||
await wait(`location.hash === '#/orgs/beta/projects/threads/${T.b1}'`);
|
||||
await wait(`!document.querySelector('[data-assistant]') && /Beta tokens rotate nightly/.test(document.getElementById('bus-view').innerText)`);
|
||||
// From another page: the same.
|
||||
await b.evaluate(`(() => { const a = document.createElement('a'); a.href = '#/orgs/alpha/projects/threads/${T.a1}'; a.setAttribute('data-full-view', ''); a.textContent = 'thread'; document.body.append(a); a.click(); a.remove(); })()`);
|
||||
await wait(`location.hash === '#/orgs/alpha/projects/threads/${T.a1}' && !document.querySelector('[data-assistant]')`);
|
||||
});
|
||||
|
||||
// SP7 review shots: the title and the pane's tools (Full view, Messages, close) share the header's
|
||||
// first row, the organization and project chips sit under it, and nothing spills out of the pane.
|
||||
const headerRows = `[...document.querySelectorAll('[data-assist-pane]')].map(p => {
|
||||
const h = p.querySelector('.assist-pane-h').getBoundingClientRect();
|
||||
const r = s => p.querySelector(s).getBoundingClientRect();
|
||||
const mid = q => Math.round(q.top + q.height / 2);
|
||||
const row = ['[data-assist-title]', '[data-assist-pane-full]', '[data-assist-pane-messages]', '[data-assist-close]'].map(s => mid(r(s)));
|
||||
const inside = [...p.querySelectorAll('.assist-pane-h > *, .assist-pane-h a, .assist-pane-h button')].every(e => { const q = e.getBoundingClientRect(); return q.left >= h.left - 1 && q.right <= h.right + 1; });
|
||||
return { oneRow: Math.max(...row) - Math.min(...row) <= 4, chipsBelow: r('[data-assist-chips]').top >= r('[data-assist-title]').bottom - 1, inside };
|
||||
})`;
|
||||
// At 800 three panes are too narrow for one row: the tools wrap, and none of them leaves the pane.
|
||||
const THREE = [`alpha:${T.a1}`, `alpha:${T.a2}`, `beta:${T.b1}`];
|
||||
for (const [width, height, keys, oneRow] of [[1440, 900, THREE, true], [800, 900, THREE, null], [761, 900, THREE, null], [390, 844, [`beta:${T.b1}`], true]]) {
|
||||
test(`SP7: a pane header keeps its tools inside the pane${oneRow ? ', title and tools on one row with the chips below' : ''}, at ${width}`, async t => {
|
||||
const { b, wait } = await open(t, { width, height, hash: `#/assistant?open=${keys.join(',')}` });
|
||||
await wait(keys.map(k => ready(k)).join(' && '));
|
||||
await wait(`[...document.querySelectorAll('[data-assist-pane] [data-ws-orgchip]')].length === ${keys.length}`);
|
||||
const rows = await b.evaluate(headerRows);
|
||||
assert.deepEqual(rows.map(r => (oneRow ? r : { ...r, oneRow: null })), keys.map(() => ({ oneRow, chipsBelow: true, inside: true })));
|
||||
});
|
||||
}
|
||||
@@ -165,3 +165,9 @@ test('S4 fix stale project gather cannot enrich a newer request revision', async
|
||||
const x=await b.evaluate(`({revision:document.querySelector('.project-request-intro').dataset.requestRevision,prd:document.querySelector('[data-work-prd]').textContent,files:document.querySelector('[data-work-files]').textContent})`);
|
||||
assert.equal(x.revision,'2');assert.doesNotMatch(x.prd,/v3|Approved/);assert.doesNotMatch(x.files,/4 draft/);
|
||||
});
|
||||
// SP7 review: the requirement view sends the person to the conversation's Request details, which a pane
|
||||
// does not have, so both of its "Open conversation" links are marked as the full view.
|
||||
test('SP7 requirement view links the conversation\'s full view, not a pane',async t=>{const {b,wait}=await setup(t,'/projects/'+P+'/requirements');await wait(`document.querySelector('[data-prd]')`);const links=await b.evaluate(`[...document.querySelectorAll('[data-project-content] a')].filter(a=>a.textContent.trim()==='Open conversation').map(a=>a.hasAttribute('data-full-view'))`);assert.deepEqual(links,[true,true]);});
|
||||
// SP7 re-check: the overview's "Review decision" and its two request counts lead to a request, which only
|
||||
// the conversation's full view shows, so each link that names a conversation is marked as the full view.
|
||||
test('SP7 overview decision and request links go to the conversation\'s full view',async t=>{const {b,wait}=await setup(t,'/projects');await b.evaluate(`reviewStates['request-1'].wait={kind:'decision'};document.querySelector('#refresh').click();location.hash='#/projects/${P}'`);await wait(`document.querySelector('.project-decision a')`);const got=await b.evaluate(`({decision:[...document.querySelectorAll('.project-decision a')].map(a=>[a.textContent.trim(),a.hasAttribute('data-full-view'),/\\/threads\\//.test(a.getAttribute('href'))]),metrics:[...document.querySelectorAll('.project-metrics a')].map(a=>[a.querySelector('span').textContent,/\\/threads\\//.test(a.getAttribute('href')),a.hasAttribute('data-full-view')])})`);assert.deepEqual(got,{decision:[['Review decision',true,true]],metrics:[['Requests in progress',true,true],['Needs your input',true,true],['Conversations',false,false],['Files',false,false]]});});
|
||||
@@ -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);
|
||||
|
||||
@@ -188,7 +188,7 @@ test('Inbox lists both organizations, copies a command with --org and the full i
|
||||
assert.deepEqual(items.map(([k]) => k).sort(), [`acme:${ACME_DECISION}`, `beta:${BETA_DECISION}`]);
|
||||
// Each row names its organization, project and session.
|
||||
const acme = items.find(([k]) => k.startsWith('acme'))[1];
|
||||
assert.match(acme, /Acme Studio/); assert.match(acme, /Website/); assert.match(acme, /coder/); assert.match(acme, /gated/);
|
||||
assert.match(acme, /Acme Studio/); assert.match(acme, /Website/); assert.match(acme, /coder/); assert.match(acme, /Gated/);
|
||||
const beta = items.find(([k]) => k.startsWith('beta'))[1];
|
||||
assert.match(beta, /Beta Works/); assert.match(beta, /pm/);
|
||||
// SP3: a project chip only where the item has a project.
|
||||
|
||||
Reference in new issue
Block a user