fix(webui): eight single-pane fixes, agent counts agree, Done this week filters Tasks, Resume only when stopped, pane drafts kept, class words, Dashboard with no organizations (#1873) #1975
No files matched your search
@@ -299,9 +299,34 @@
|
||||
}
|
||||
|
||||
// ---------- panes ----------
|
||||
// SP11: a pane's unsent message is kept per thread for the life of the browser tab, so leaving the
|
||||
// Assistant page (a Full view link included) and coming back puts it in the composer again.
|
||||
// sessionStorage can be missing or refuse a write; the Map still keeps it while the page lives.
|
||||
const DRAFTS = 'mosaic-assistant-drafts';
|
||||
const kept = (() => {
|
||||
try {
|
||||
const v = JSON.parse(sessionStorage.getItem(DRAFTS) || 'null');
|
||||
return new Map(v && typeof v === 'object' && !Array.isArray(v) ? Object.entries(v).filter(([k, t]) => typeof t === 'string' && t.trim() && parse(`open=${k}`).keys[0] === k) : []);
|
||||
} catch { return new Map(); }
|
||||
})();
|
||||
const saveKept = () => { try { if (kept.size) sessionStorage.setItem(DRAFTS, JSON.stringify(Object.fromEntries(kept))); else sessionStorage.removeItem(DRAFTS); } catch { /* the Map still holds it */ } };
|
||||
const typedIn = p => p.chat?.draft?.() || p.draft || p.el?.querySelector('form textarea')?.value || '';
|
||||
// Signed out, by an expired session or by the Sign out form: the kept drafts go and nothing is kept
|
||||
// again, not even by the pagehide the form's own submit causes, so the next person to sign in on
|
||||
// this tab never sees text typed before.
|
||||
let draftsOff = false;
|
||||
function keep(p) { if (draftsOff) return; const text = typedIn(p); if (text.trim()) kept.set(p.key, text); else kept.delete(p.key); saveKept(); }
|
||||
function drop(p) { if (kept.delete(p.key)) saveKept(); }
|
||||
window.addEventListener('pagehide', () => { if (mounted) for (const p of mounted.panes) keep(p); });
|
||||
function forgetDrafts() {
|
||||
draftsOff = true; kept.clear(); saveKept();
|
||||
if (mounted) for (const p of mounted.panes) { p.draft = ''; const t = p.el?.querySelector('form textarea'); if (t) t.value = ''; }
|
||||
}
|
||||
document.addEventListener('mosaic-signed-out', forgetDrafts);
|
||||
document.addEventListener('submit', e => { if (e.target?.id === 'sign-out') forgetDrafts(); }, true);
|
||||
function makePane(key) {
|
||||
const { org, id } = split(key);
|
||||
const p = { key, org, id, n: ++paneSeq, el: null, chat: null, first: true, title: null, draft: '', held: false, seq: 0 };
|
||||
const p = { key, org, id, n: ++paneSeq, el: null, chat: null, first: true, title: null, draft: kept.get(key) || '', held: false, seq: 0 };
|
||||
const box = mounted.root.querySelector('[data-assist-panes]');
|
||||
box.insertAdjacentHTML('beforeend', paneShell(p));
|
||||
p.el = box.lastElementChild;
|
||||
@@ -320,7 +345,9 @@
|
||||
}
|
||||
// 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();
|
||||
// SP11: a pane whose send is in flight, or whose sent message the conversation does not show yet,
|
||||
// is held too, until its receipt is in.
|
||||
const holds = p => !!p.draft.trim() || !!p.chat?.hasDraft() || !!p.chat?.busy?.();
|
||||
function place(key) {
|
||||
const had = mounted.panes.find(p => p.key === key);
|
||||
if (had) return had;
|
||||
@@ -328,11 +355,11 @@
|
||||
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);
|
||||
drop(old); 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.`;
|
||||
const REFUSED = `That thread was not opened: all ${MAX} panes hold an unsent message or a message waiting to show. Send or clear one, wait for a sent message to show, or close a pane, then open the thread again.`;
|
||||
function add(key) {
|
||||
if (!mounted) return false;
|
||||
const p = place(key);
|
||||
@@ -360,10 +387,13 @@
|
||||
else location.hash = `#/assistant?add=${key}`;
|
||||
});
|
||||
|
||||
// SP11: closing a pane that holds unsent text asks first; a close drops the kept text.
|
||||
function closePane(p) {
|
||||
if (!mounted || !p) return;
|
||||
const at = mounted.panes.indexOf(p);
|
||||
if (at < 0) return;
|
||||
if (typedIn(p).trim() && !window.confirm(`Close ${p.title || 'this thread'}? Its unsent message is lost.`)) return;
|
||||
drop(p);
|
||||
mounted.notice = '';
|
||||
p.chat?.dispose(); p.chat = null; p.el.remove();
|
||||
mounted.panes.splice(at, 1);
|
||||
@@ -484,7 +514,7 @@
|
||||
function unmount() {
|
||||
if (!mounted) return;
|
||||
clearTimeout(mounted.timer);
|
||||
for (const p of mounted.panes) p.chat?.dispose();
|
||||
for (const p of mounted.panes) { keep(p); p.chat?.dispose(); }
|
||||
window.removeEventListener('resize', fit);
|
||||
mounted = null; mountGen++;
|
||||
}
|
||||
@@ -495,7 +525,7 @@
|
||||
if (typeof query === 'string') {
|
||||
const want = parse(query);
|
||||
if (want.named && want.keys.join(',') !== mounted.panes.map(p => p.key).join(',')) {
|
||||
for (const p of [...mounted.panes]) if (!want.keys.includes(p.key)) { p.chat?.dispose(); p.el.remove(); }
|
||||
for (const p of [...mounted.panes]) if (!want.keys.includes(p.key)) { keep(p); p.chat?.dispose(); p.el.remove(); }
|
||||
const kept = new Map(mounted.panes.map(p => [p.key, p]));
|
||||
mounted.panes = want.keys.map(k => kept.get(k) || makePane(k));
|
||||
const box = mounted.root.querySelector('[data-assist-panes]');
|
||||
|
||||
@@ -208,7 +208,7 @@
|
||||
out.groupAs = m?.from_role === 'human' ? 'request' : m?.decision ? 'decision' : 'other';
|
||||
} else if (r.table === 'decisions') {
|
||||
out.kind = 'decision.raised'; out.decision = r.id; out.actor = { role: r.raised_by_role, run: r.raised_by_run };
|
||||
out.text = `Raised a ${r.class} decision for ${r.route_to}: ${String(r.question).split('\n')[0]}`;
|
||||
out.text = `Raised a ${String(classWord(r.class)).toLowerCase()} decision for ${r.route_to}: ${String(r.question).split('\n')[0]}`;
|
||||
} else if (r.table === 'decision_events') {
|
||||
// The human answers from the CLI (via cli) or, since row 252 (#1753), a grant request from the
|
||||
// signed-in Console (via console); every other row is the broker's.
|
||||
@@ -243,7 +243,9 @@
|
||||
const badge = (text, kind = 'muted') => `<span class="badge badge-${kind}">${txt(text)}</span>`;
|
||||
// Class chip: the decision class, the action in mono. BLOCKING is its own chip (CSS capitalises it; the text stays lowercase).
|
||||
const CLASSES = ['routine', 'within-role', 'cross-role', 'gated'];
|
||||
const chip = (cls, action) => `<span class="tag${CLASSES.includes(cls) ? ` tag-${cls}` : ''}">${txt(cls)} · <code>${txt(action)}</code></span>`;
|
||||
// SP11: the class in the Inbox's words, from workspace.js's map.
|
||||
const classWord = cls => window.MosaicWorkspace?.classWord?.(cls) ?? cls;
|
||||
const chip = (cls, action) => `<span class="tag${CLASSES.includes(cls) ? ` tag-${cls}` : ''}">${txt(classWord(cls))} · <code>${txt(action)}</code></span>`;
|
||||
const blockChip = text => `<span class="tag tag-block">${txt(text)}</span>`;
|
||||
// An empty state says what would appear and where it comes from.
|
||||
const empty = (what, from) => `<div class="empty"><b>${what}</b>${from ? `<p>${from}</p>` : ''}</div>`;
|
||||
@@ -354,7 +356,7 @@
|
||||
function repliesHtml() {
|
||||
if (replies === null) return '<p class="small warn">The replies to it could not be read. Read again to retry; <code>mosaic inbox</code> in a terminal lists them.</p>';
|
||||
if (!replies.length) return '<p class="small muted">No reply yet. One shows here when the role answers, as the page refreshes.</p>';
|
||||
return `<h3 class="small">Replies <span class="count">${replies.length}</span></h3><ol class="s1-plain s1-replies">${replies.map(m => `<li><p class="small muted">${txt(m.from_role)} ${when(m.at)} · ${txt(m.class)} · <a href="#/trail/message/${enc(m.id)}">trail</a></p><p class="s1-q">${txt(m.body)}</p></li>`).join('')}</ol>${replies.length >= REPLIES ? `<p class="small muted">The first ${REPLIES} replies show here.</p>` : ''}`;
|
||||
return `<h3 class="small">Replies <span class="count">${replies.length}</span></h3><ol class="s1-plain s1-replies">${replies.map(m => `<li><p class="small muted">${txt(m.from_role)} ${when(m.at)} · ${txt(classWord(m.class))} · <a href="#/trail/message/${enc(m.id)}">trail</a></p><p class="s1-q">${txt(m.body)}</p></li>`).join('')}</ol>${replies.length >= REPLIES ? `<p class="small muted">The first ${REPLIES} replies show here.</p>` : ''}`;
|
||||
}
|
||||
const draft = { to: '', body: '' };
|
||||
function composerHtml() {
|
||||
@@ -707,7 +709,7 @@
|
||||
if (after !== null && !SAFE.test(after)) return notFound('The message cursor is not a message id.');
|
||||
const page = await get('messages', after === null ? undefined : after);
|
||||
const body = page.rows.length ? `<ul id="message-list" class="waiting s1-replies">${page.rows.map(m =>
|
||||
`<li class="wait-item"><p class="small muted">${txt(m.from_role)} · ${txt(m.class)} · ${when(m.at)} · ${m.read ? 'Read' : 'Unread'} · <a href="#/trail/message/${enc(m.id)}">trail</a></p><p class="s1-message">${messageText(m)}</p></li>`).join('')}</ul>`
|
||||
`<li class="wait-item"><p class="small muted">${txt(m.from_role)} · ${txt(classWord(m.class))} · ${when(m.at)} · ${m.read ? 'Read' : 'Unread'} · <a href="#/trail/message/${enc(m.id)}">trail</a></p><p class="s1-message">${messageText(m)}</p></li>`).join('')}</ul>`
|
||||
: empty('No messages to you.', 'Messages appear here as the bus is read.');
|
||||
return `${head('Messages', `${readNote(get)} Every message to you, newest first. Reading here marks nothing read.`)}
|
||||
<p class="small muted">${page.total} messages · ${page.unread} unread</p>${body}
|
||||
@@ -1689,7 +1691,9 @@
|
||||
if (parts.includes(null)) return notFound();
|
||||
const [a, b, c] = parts, n = parts.length;
|
||||
// #1873 SP1: `#/dashboard` is the system dashboard, every organization on one page.
|
||||
if ((n === 0 || (a === 'dashboard' && n === 1)) && window.MosaicOrg) return window.MosaicWorkspace.home();
|
||||
// SP11: with no organization it is still the Dashboard (it says so and offers New organization);
|
||||
// #/ is then the Console's own board.
|
||||
if ((n === 0 && window.MosaicOrg) || (a === 'dashboard' && n === 1 && window.MosaicWorkspace)) return window.MosaicWorkspace.home();
|
||||
if (a === 'projects' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.projectsPage(q);
|
||||
if (a === 'organization' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.dashboard();
|
||||
// SP3: #/inbox is every organization's items; #/orgs/<id>/inbox is that list for one, with its hire form.
|
||||
@@ -1737,7 +1741,7 @@
|
||||
const paused = () => $('pause')?.getAttribute('aria-pressed') === 'true';
|
||||
// A file open in the editor is never refreshed from under its draft; Reload is the person's to press.
|
||||
// The cross-organization pages (U3) poll at the workspace read's pollMs.
|
||||
const wsPage = path => !!window.MosaicOrg && (path === '/' || path === '' || path === '/dashboard' || path === '/projects' || path === '/inbox' || path === '/agents' || path === '/tasks' || /^\/workflows(\/[^/]+)?$/.test(path));
|
||||
const wsPage = path => (path === '/dashboard' && !!window.MosaicWorkspace) || !!window.MosaicOrg && (path === '/' || path === '' || path === '/dashboard' || path === '/projects' || path === '/inbox' || path === '/agents' || path === '/tasks' || /^\/workflows(\/[^/]+)?$/.test(path));
|
||||
function schedule() { clearTimeout(timer); if (!paused() && !out() && !routeOf().startsWith('/docs/')) timer = setTimeout(() => render(false), wsPage(routeOf().split('?')[0]) ? window.MosaicWorkspace?.pollMs || 10000 : 10000); }
|
||||
// Keep focus, open <details> and the copy status across a refresh of the same page.
|
||||
// In the send form, the focused field and its caret too (its values come from `draft`).
|
||||
|
||||
@@ -90,6 +90,16 @@
|
||||
signedOut = true; forget();
|
||||
document.dispatchEvent(new Event('mosaic-signed-out'));
|
||||
}
|
||||
// SP11: whether the thread's agent runs no process, from the bus instances read the Agents page uses.
|
||||
// true: paused or stopped; false: running; null: the read failed or does not list the agent.
|
||||
async function agentIdle(instance, signal) {
|
||||
try {
|
||||
const res = await fetch(pane ? `/api/orgs/${encodeURIComponent(env.org)}/bus/instances` : '/api/bus/instances', { cache: 'no-store', redirect: 'manual', signal });
|
||||
if (!res.ok || res.type === 'opaqueredirect') return null;
|
||||
const item = (await res.json())?.rows?.[0]?.instances?.[instance];
|
||||
return item && typeof item === 'object' && Array.isArray(item.running) ? item.running.length === 0 : null;
|
||||
} catch (err) { if (err.name === 'AbortError') throw err; return null; }
|
||||
}
|
||||
async function request(path, value, signal) {
|
||||
if (out()) throw new ChatError('signed-out');
|
||||
let res;
|
||||
@@ -317,8 +327,10 @@
|
||||
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${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>` : ''}`;
|
||||
// SP11: Resume shows unless the instances read says the agent is running.
|
||||
const resumable = threadWrites() && current.agentIdle !== false;
|
||||
const resume = resumable ? `<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>${resumable ? `<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>${resumable ? `<button class="btn" type="button" id="chat-resume"${disabledSession(s.pending || current.channelDisabled)}>Resume assistant</button>` : ''}`;
|
||||
return `<section class="chat-conversation${redesigned ? ' project-chat' : ''}" data-project="${esc(t.project)}" data-thread="${esc(t.id)}" data-session="${esc(current.session)}">${pane ? '' : `<p>${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}</p>`}${frame}${libraryLine(libraryOf(current, t))}${details}${redesigned ? '<p class="small muted" data-conversation-request-status></p>' : ''}<ol class="chat-log" tabindex="0" aria-label="Thread conversation">${messages || (current.readFailed ? '<li class="muted">Messages unavailable.</li>' : '<li class="muted">No messages yet.</li>')}</ol><div class="chat-paging">${current.readFailed ? '<button type="button" class="btn" data-chat-retry>Read again</button>' : s.more ? '<p>Showing earlier history. Read forward to reach the current conversation.</p><button class="btn" type="button" id="chat-next">Read next messages</button>' : '<p class="muted">Caught up to the last read.</p>'}<button class="btn" type="button" id="chat-new"${s.follow ? ' hidden' : ''}>New messages — go to end</button></div>${threadWrites() ? `<form id="chat-send-form"><label for="chat-body">Message ${txt(t.instance)}</label><textarea id="chat-body" rows="4" placeholder="${redesigned ? `Message ${esc(t.instance)}` : 'Write a message…'}"${disabledSession(s.pending)}>${esc(s.draft)}</textarea><p class="chat-audience">To: ${redesigned ? `<span class="room-chip" aria-label="Recipient ${esc(t.instance)}">${txt(t.instance)}</span>` : `${txt(t.instance)} · Private conversation`}</p>${redesigned ? '<p class="project-composer-hint">Enter adds a new line.</p>' : ''}<button class="btn primary" type="submit"${disabledSession(s.pending || current.channelDisabled)}>Send</button></form>` : ''}<p id="chat-receipt" role="status">${txt(receipt)}</p>${pane ? resume : ''}${redesigned || pane ? '' : '<section class="chat-files"><h2>Files</h2><div data-chat-files>Files unavailable.</div></section><div data-chat-summary></div>'}</section>`;
|
||||
}
|
||||
// C2-10c: rooms reuse the same state, renderer and generation fences as threads.
|
||||
@@ -789,7 +801,7 @@
|
||||
paint(); return;
|
||||
}
|
||||
const old = current;
|
||||
current = { container, kind, id, orgArchived: old?.orgArchived ?? false, loading: true, archiveUnknown: kind === 'thread' && !user && !tester, settings: !!settings, section, path: originalPath, canSend, user, tester, chatGrants: [], thread: old && old.id === id ? old.thread : null, shared: old && old.id === id ? old.shared : false, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], instances: old?.instances ?? [], instanceError: kind === 'project' ? 'Reading thread instances…' : '', error: '', readFailed: false, channelDisabled: old?.channelDisabled ?? false, refused: false, legacyDefault: old && old.id === id ? old.legacyDefault : false, sessions: old && old.id === id ? old.sessions ?? [] : [], session: match[3] ?? (old && old.id === id ? old.session : null), key: match[3] ? id + '/' + match[3] : old && old.id === id ? old.key : id };
|
||||
current = { container, kind, id, orgArchived: old?.orgArchived ?? false, loading: true, archiveUnknown: kind === 'thread' && !user && !tester, settings: !!settings, section, path: originalPath, canSend, user, tester, chatGrants: [], thread: old && old.id === id ? old.thread : null, shared: old && old.id === id ? old.shared : false, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], instances: old?.instances ?? [], instanceError: kind === 'project' ? 'Reading thread instances…' : '', error: '', readFailed: false, channelDisabled: old?.channelDisabled ?? false, refused: false, legacyDefault: old && old.id === id ? old.legacyDefault : false, sessions: old && old.id === id ? old.sessions ?? [] : [], session: match[3] ?? (old && old.id === id ? old.session : null), key: match[3] ? id + '/' + match[3] : old && old.id === id ? old.key : id, agentIdle: old && old.id === id ? old.agentIdle : null };
|
||||
current.projectArchived = current.projects.some(p => p.id === (kind === 'project' ? id : current.thread?.project) && p.archived);
|
||||
if (kind === 'thread' && old?.kind === 'thread' && old.id === id && !navigated && old.archiveUnknown === false && old.projects.some(p => p.id === old.thread?.project)) current.archiveUnknown = false;
|
||||
if (current.projectArchived) current.archiveUnknown = false;
|
||||
@@ -873,6 +885,10 @@
|
||||
if (g !== generation || out()) return;
|
||||
current.shares = shares.rows;
|
||||
}
|
||||
if (kind === 'thread' && current.thread && threadWrites()) {
|
||||
current.agentIdle = await agentIdle(current.thread.instance, signal);
|
||||
if (g !== generation || out()) return;
|
||||
}
|
||||
let s = null, page = null, after = null;
|
||||
if (kind === 'thread' && catalog === null) {
|
||||
current.legacyDefault = true; current.sessions = []; current.session = null;
|
||||
@@ -1274,12 +1290,16 @@
|
||||
}
|
||||
on('mosaic-signed-out', () => { signedOut = true; forget(); });
|
||||
const hasDraft = () => [...states.values()].some(s => Boolean(s.draft?.trim())) || [...forms.values()].some(f => Boolean(f.name?.trim() || f.repo || f.instanceChosen));
|
||||
// SP11: the unsent text of a pane's thread, and whether a write is in flight or a sent message
|
||||
// still waits for the conversation to show it.
|
||||
const draft = () => [...states.values()].find(s => s.draft?.trim())?.draft || '';
|
||||
const busy = () => [...states.values()].some(s => s.pending || !!s.receiptId);
|
||||
// Ends a pane instance: its listeners, reads and observers stop and its container is left as is.
|
||||
function dispose() {
|
||||
life.abort(); generation++; controller?.abort(); transcriptObserver?.disconnect(); transcriptResize?.disconnect();
|
||||
current = null; lastPaint = null;
|
||||
}
|
||||
return { render, leave, forget, refresh, projectPath, hasDraft, dispose };
|
||||
return { render, leave, forget, refresh, projectPath, hasDraft, draft, busy, dispose };
|
||||
}
|
||||
const main = chatInstance();
|
||||
const ORG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
@@ -1288,7 +1308,7 @@
|
||||
function pane(org) {
|
||||
if (typeof org !== 'string' || !ORG.test(org)) throw new TypeError('pane needs an organization id');
|
||||
const chat = chatInstance({ org, prefix: `chat-p${++panes}-` });
|
||||
return Object.freeze({ render: chat.render, refresh: chat.refresh, dispose: chat.dispose, hasDraft: chat.hasDraft });
|
||||
return Object.freeze({ render: chat.render, refresh: chat.refresh, dispose: chat.dispose, hasDraft: chat.hasDraft, draft: chat.draft, busy: chat.busy });
|
||||
}
|
||||
window.MosaicChat = Object.freeze({ render: main.render, leave: main.leave, forget: main.forget, projectPath: main.projectPath, hasDraft: main.hasDraft, pane });
|
||||
})();
|
||||
@@ -118,7 +118,8 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date(
|
||||
return read;
|
||||
};
|
||||
let previous = location.hash, skipGuard = false;
|
||||
const dirty = () => window.MosaicChat?.hasDraft?.() || window.MosaicAssistant?.hasDraft?.() || window.MosaicOrgCreate?.hasDraft?.() || window.MosaicWorkflows?.hasDraft?.() || [...document.querySelectorAll('#reply, #conv-reply, #doc-source')].some(el => !!el.value?.trim());
|
||||
// SP11: the Assistant keeps its panes' unsent text per thread, so it is not asked about here.
|
||||
const dirty = () => window.MosaicChat?.hasDraft?.() || window.MosaicOrgCreate?.hasDraft?.() || window.MosaicWorkflows?.hasDraft?.() || [...document.querySelectorAll('#reply, #conv-reply, #doc-source')].some(el => !!el.value?.trim());
|
||||
// A switch scopes later reads to the new organization; listeners drop what they held for the old one.
|
||||
function rescope(seg) {
|
||||
const from = current;
|
||||
|
||||
@@ -41,7 +41,12 @@
|
||||
// The layout is remembered per viewer and per width: a phone opens on List until its viewer picks
|
||||
// Kanban, a wide screen on Kanban until its viewer picks List.
|
||||
const layouts = (() => { try { const v = JSON.parse(load(LAYOUT) || '{}'); return v && typeof v === 'object' ? v : {}; } catch { return {}; } })();
|
||||
const filter = { org: '', project: '', assignee: '', state: '', label: '', q: '' };
|
||||
// done: 'week' shows the tasks the Dashboard's Done this week tile counts. It stays in the browser:
|
||||
// the rows already carry done and their last change, the same test the tile's count uses.
|
||||
const filter = { org: '', project: '', assignee: '', state: '', label: '', q: '', done: '' };
|
||||
const WEEK_MS = 7 * 24 * 3600 * 1000;
|
||||
const doneThisWeek = t => t.done && t.changedAt && Date.now() - Date.parse(t.changedAt) <= WEEK_MS;
|
||||
const DONE_NOTE = 'Done this week counts done tasks by their last change; the bus records no time a task was done.';
|
||||
// The board style in force: the filtered project's when it has one, else the one organization's the
|
||||
// view is narrowed to (by the org chip or by a project of that org), else none.
|
||||
function activeBoard() {
|
||||
@@ -167,6 +172,7 @@
|
||||
&& (!filter.assignee || (filter.assignee === '-' ? !t.assignees.length : t.assignees.includes(filter.assignee)))
|
||||
&& (!filter.state || stateOf(t) === filter.state)
|
||||
&& (!filter.label || t.labels.map(String).includes(filter.label))
|
||||
&& (!filter.done || doneThisWeek(t))
|
||||
&& (!q || !needle || `${t.title ?? ''} ${shortId(t.ref)} ${projectName(t) ?? ''}`.toLowerCase().includes(needle)));
|
||||
}
|
||||
const select = (name, label, options, focus) => `<label class="tb-sel"><span class="ws-label">${esc(label)}</span><select data-tb-filter="${name}" data-focus-key="${focus}">${options}</select></label>`;
|
||||
@@ -189,6 +195,7 @@
|
||||
const labelSel = labels.length ? select('label', 'Label', opt('', 'Any', filter.label) + labels.map(l => opt(l, l, filter.label)).join(''), 'tb-label') : '';
|
||||
return `<div class="tb-toolbar">
|
||||
${scope ? '' : `<div class="tb-chips" role="group" aria-label="Organization">${chips}</div>`}
|
||||
${filter.done ? '<button type="button" class="tb-f plain" data-tb-done aria-pressed="true" aria-label="Clear Done this week: done tasks last changed in the past 7 days" data-focus-key="tb-done">Done this week <span aria-hidden="true">×</span></button>' : ''}
|
||||
${select('project', 'Project', projectOpts, 'tb-project')}${select('assignee', 'Assignee', assigneeOpts, 'tb-assignee')}${select('state', 'State', stateOpts, 'tb-state')}${labelSel}
|
||||
<div class="tb-right"><label class="tb-search">${icon('search')}<input type="search" placeholder="Find a task" aria-label="Find a task" data-tb-q data-focus-key="tb-q" value="${esc(filter.q)}" autocomplete="off"></label></div>
|
||||
</div>`;
|
||||
@@ -244,8 +251,9 @@
|
||||
// total; with one of those on, the line says how many match rather than inventing an "of N".
|
||||
// The scope's org= is where the board reads, not a filter.
|
||||
const onServer = new URLSearchParams(readQuery()).size > (scope ? 2 : 1), n = rows.length;
|
||||
const line = onServer ? `<p class="tb-count">Showing ${n} ${n === 1 ? 'task that matches' : 'tasks that match'} the filters.</p>`
|
||||
: n !== all ? `<p class="tb-count">Showing ${n} of ${all} tasks.</p>` : '';
|
||||
const note = filter.done ? ` ${DONE_NOTE}` : '';
|
||||
const line = onServer ? `<p class="tb-count">Showing ${n} ${n === 1 ? 'task that matches' : 'tasks that match'} the filters.${note}</p>`
|
||||
: n !== all ? `<p class="tb-count">Showing ${n} of ${all} tasks.${note}</p>` : note ? `<p class="tb-count">${note.trim()}</p>` : '';
|
||||
// An empty result is only exhaustive when every organization's tasks were read.
|
||||
const unread = snap.orgs.filter(o => !tasksRead(o));
|
||||
const none = what => (unread.length ? `${what} in the organizations that answered. Tasks from ${unread.map(o => o.label).join(', ')} could not be read.` : `${what}.`);
|
||||
@@ -349,6 +357,7 @@
|
||||
const org = String(q.get('org') || ''), project = String(q.get('project') || '');
|
||||
filter.org = scope || (ORG_ID.test(org) ? org : '');
|
||||
filter.project = PROJECT_SELECTOR.test(project) || project === '-' ? project : '';
|
||||
filter.done = q.get('done') === 'week' ? 'week' : '';
|
||||
if (filter.org && filter.project && filter.project !== '-' && !filter.project.startsWith(`${filter.org}:`)) filter.project = '';
|
||||
if (dragging) await Promise.race([dragEnded, new Promise(r => setTimeout(r, 30000))]);
|
||||
// Two renders at once (a first load, a poll during a filter change): the replaced one draws from the newer read.
|
||||
@@ -592,6 +601,7 @@
|
||||
ws()?.setQuery?.({ org: org || null, ...(clear ? { project: null } : {}) }, true);
|
||||
return;
|
||||
}
|
||||
if (e.target.closest('[data-tb-done]')) { filter.done = ''; paint(); ws()?.setQuery?.({ done: null }, true); return; }
|
||||
const open = e.target.closest('[data-tb-open]');
|
||||
if (open) { const t = find(open.dataset.tbOpen); if (t) openDialog(editForm(t), open.dataset.focusKey, t); return; }
|
||||
const add = e.target.closest('[data-tb-new]');
|
||||
|
||||
@@ -68,6 +68,12 @@
|
||||
// An agent's activity is the state the bus derives for the run holding its role. A live process
|
||||
// alone says only running, never working or idle.
|
||||
const ACTIVITY = { working: ['s-working', '●', 'working'], waiting: ['s-waiting', '◐', 'waiting on you'], idle: ['s-idle', '–', 'idle'] };
|
||||
// SP11 (#1873): the agent counts share two words. Running is an agent row with a live process,
|
||||
// whatever it is doing; working is a running one whose role session is working.
|
||||
const LIVE_STATES = new Set(['working', 'waiting', 'idle', 'running']);
|
||||
const runningOf = rows => rows.filter(a => LIVE_STATES.has(a.state)).length;
|
||||
const workingOf = rows => rows.filter(a => a.state === 'working').length;
|
||||
const agentNote = (run, waiting) => `${run} running${waiting ? `, ${waiting} waiting on you` : ''}`;
|
||||
const activity = (name, item, agents) => {
|
||||
const on = running(item) > 0;
|
||||
const held = on && agents ? agents.find(a => a?.role === name && item.running.includes(a.holder_run)) : null;
|
||||
@@ -280,9 +286,10 @@
|
||||
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>`;
|
||||
// SP11 (#1873): the line beside the disabled reload says how a change to the organization file loads.
|
||||
const reload = `<button type="button" class="ws-btn" data-org-reload data-focus-key="org-reload" disabled aria-describedby="${esc(why)}" title="Reloading without a restart is not available yet">${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>`;
|
||||
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)}">The bus reads the organization file when it starts. To load a change to the file, restart the bus.</span></p>`;
|
||||
|
||||
const projectName = pid => projectRows?.find(p => p.id === pid)?.name || pid;
|
||||
const docChips = !docRead ? '<span class="note">Docs unavailable</span>' : docRead.failed ? '<span class="note">Docs unavailable</span>' : docRead.rows.length ? docRead.rows.slice(0, 6).map(d => `<a class="ws-doc" href="#/docs/${enc(d.project)}/${enc(d.path)}" data-focus-key="${esc(`doc:${d.project}:${d.path}`)}" title="${esc(`${d.project}/${d.path}`)}">${icon('doc')}${esc(docName(d.path))}<small>${esc(projectName(d.project))}</small></a>`).join('') : '<span class="note">No docs yet</span>';
|
||||
@@ -297,27 +304,35 @@
|
||||
const agentsWhy = whyNot(['agents'], false) || (orgAgents ? null : 'the agent read did not answer');
|
||||
const kpi = orgsOf(ws), oq = `org=${enc(id)}`;
|
||||
const orgTile = (key, title, href, why, fig) => kpiTile(kpi, { key, name: title, href, ...(why ? { none: `Not read: ${esc(why)}` } : fig) });
|
||||
const working = orgAgents?.filter(a => a.state === 'working').length ?? 0, waiting = orgAgents?.filter(a => a.state === 'waiting').length ?? 0;
|
||||
const working = orgAgents ? workingOf(orgAgents) : 0, waiting = orgAgents?.filter(a => a.state === 'waiting').length ?? 0;
|
||||
const orgTiles = `<div class="ws-tiles" data-org-tiles role="group" aria-label="Key figures">
|
||||
${orgTile('agents', 'Agents working', `#/agents?${oq}&state=working`, agentsWhy, { big: working, small: `of ${orgAgents?.length ?? 0}`, delta: waiting ? `${waiting} waiting on you` : '' })}
|
||||
${orgTile('agents', 'Agents working', `#/agents?${oq}&state=working`, agentsWhy, { big: working, small: `of ${orgAgents?.length ?? 0}`, delta: orgAgents ? agentNote(runningOf(orgAgents), waiting) : '' })}
|
||||
${orgTile('tasks', 'Open tasks', `#/tasks?${oq}`, whyNot(['tasks']), { big: num(mine?.openTasks) })}
|
||||
${orgTile('needs', 'Needs you', `#/inbox?${oq}`, whyNot(['inbox']), { big: num(mine?.needsYou), small: num(mine?.needsYou) === 1 ? 'item' : 'items', delta: num(mine?.needsYou) && mine?.oldestNeedsYouAt ? `oldest ${span(mine.oldestNeedsYouAt)}` : 'nothing waiting' })}
|
||||
${orgTile('done', 'Done this week', `#/tasks?${oq}`, whyNot(['tasks']), { big: num(mine?.doneThisWeek) })}
|
||||
${orgTile('done', 'Done this week', `#/tasks?${oq}&done=week`, whyNot(['tasks']), { big: num(mine?.doneThisWeek) })}
|
||||
</div>`;
|
||||
const modelOf = inst => business?.business?.instances?.find(i => i?.instance === inst)?.vars?.model;
|
||||
const live = instances ? Object.entries(instances).filter(([, item]) => running(item)) : null;
|
||||
// SP11: the sessions are the running rows the tile counts: the organization's instances and the
|
||||
// root and project copies its bus booted with. A copy shows its own name.
|
||||
const copyLive = Object.entries(copiesOf(value(2))).filter(([, c]) => c && typeof c.name === 'string' && !copyBlocked(c) && running(c)).map(([key, c]) => [key, c, c.name]);
|
||||
const live = instances ? [...Object.entries(instances).filter(([, item]) => running(item)).map(([key, item]) => [key, item, key]), ...copyLive] : null;
|
||||
// The sessions heading and rows take their activity from the session list the tile counts, so the
|
||||
// two never disagree. When the tile shows no figure they use the bus agents read instead; when
|
||||
// that failed too, the heading says working was not read rather than a figure.
|
||||
const tileAgents = orgAgents && !agentsWhy ? arr(ws.sessions).filter(x => x?.kind === 'role' && x.org === id && typeof x.role === 'string').map(x => ({ role: x.role, holder_run: x.run, state: x.state })) : agents;
|
||||
const liveWorking = live ? live.filter(([key, item]) => activity(key, item, tileAgents)[2] === 'working').length : 0;
|
||||
// A session's project and task is the thread its live run holds a claim on (thread.claims), not
|
||||
// any older conversation of the same agent. A failed read says unavailable, never "none".
|
||||
const sessionRows = (live || []).map(([agent, item]) => {
|
||||
const sessionRows = (live || []).map(([agent, item, shown]) => {
|
||||
const held = claims ? claims.filter(c => item.running.includes(c?.holder_run)) : null;
|
||||
const first = held?.[0], thread = first && threads ? threads.find(t => t?.id === first.thread) : null;
|
||||
const model = modelOf(agent), count = running(item), [acls, aglyph, aword] = activity(agent, item, agents);
|
||||
const model = modelOf(agent), count = running(item), [acls, aglyph, aword] = activity(agent, item, tileAgents);
|
||||
const more = held && held.length > 1 ? ` · ${held.length - 1} more` : '';
|
||||
const where = held === null ? '<span class="muted">Conversation unavailable</span>'
|
||||
: !first ? '<span class="muted">No conversation held right now</span>'
|
||||
: thread ? `${esc(projectName(thread.project))}<span class="t2">${esc(thread.name || thread.id)}${more}</span>`
|
||||
: threads ? `<span class="muted">Conversation</span><span class="t2">${esc(first.thread)}${more}</span>` : '<span class="muted">Conversation unavailable</span>';
|
||||
return `<tr><td>${esc(agent)}</td><td>${where}</td><td class="hide-m mono">${modelCell(agent, model)}</td><td>${dot(acls, aglyph, aword, true)}${count > 1 ? ` <span class="muted">${count} sessions</span>` : ''}</td></tr>`;
|
||||
return `<tr><td>${esc(shown)}</td><td>${where}</td><td class="hide-m mono">${modelCell(agent, model)}</td><td>${dot(acls, aglyph, aword, true)}${count > 1 ? ` <span class="muted">${count} sessions</span>` : ''}</td></tr>`;
|
||||
}).join('');
|
||||
const sessions = live === null ? (stopped ? '<p class="ws-panel tight note" data-bus-stopped>Sessions not read: the bus is stopped.</p>' : '<p class="ws-panel tight note">Sessions unavailable. The bus did not answer the instance read.</p>') : live.length ? `<div class="ws-tbl"><table><thead><tr><th scope="col">Agent</th><th scope="col">Project and task</th><th scope="col" class="hide-m">Model</th><th scope="col">Status</th></tr></thead><tbody>${sessionRows}</tbody></table></div>` : '<p class="ws-panel tight note">No active agent sessions</p>';
|
||||
const open = queue ? queue.filter(r => r?.state !== 'done').length : null;
|
||||
@@ -338,7 +353,7 @@
|
||||
${docrow}
|
||||
<div class="ws-split">
|
||||
<section aria-label="Measures and sessions">
|
||||
${secH('Active agent sessions', live ? (live.length ? `${live.length} running` : 'none running') : '', `<a href="#/agents?${oq}" data-focus-key="agents-all">This organization's agents</a>`)}
|
||||
${secH('Active agent sessions', live ? (live.length ? `${live.length} running${tileAgents ? `, ${liveWorking} working` : ', working not read'}` : 'none running') : '', `<a href="#/agents?${oq}" data-focus-key="agents-all">This organization's agents</a>`)}
|
||||
${sessions}
|
||||
${secH('Queue', queueNote, '<a href="#/queue" data-org-queue data-focus-key="queue-open">Open queue</a>')}
|
||||
${queueWhy}
|
||||
@@ -624,7 +639,9 @@
|
||||
const CLASS_CHIP = { gated: 'gated', 'within-role': 'within', routine: 'routine', 'cross-role': 'cross' };
|
||||
// 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>` : '');
|
||||
// SP11: the Inbox's word for a decision class; a class the map does not know shows as it is.
|
||||
const classWord = c => (Object.hasOwn(TYPE_WORD, c) ? TYPE_WORD[c] : c);
|
||||
const classChip = c => (c ? `<span class="ws-chip ${CLASS_CHIP[c] || ''}">${esc(classWord(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(' · ');
|
||||
@@ -669,25 +686,27 @@
|
||||
const needsN = fig('needs', ps.needs);
|
||||
const archived = per.filter(x => readers(o, SRC.projects).has(x.org)).reduce((a, x) => a + num(x.projects?.archived), 0);
|
||||
const tiles = !o.rows.length ? '<p class="ws-panel tight note" data-ws-tiles>No organizations yet, so there is nothing to count.</p>' : `<div class="ws-tiles six" data-ws-tiles role="group" aria-label="Key figures">
|
||||
${kpiTile(o, { key: 'agents', name: 'Agents working', href: '#/agents?state=working', big: agentOk.length ? sum(agentParts) : null, small: `of ${agentAll.length}`, parts: agentParts, delta: waitingOn ? `${waitingOn} waiting on you` : '', miss: [...notIn(['agents']), ...agentReads.filter(r => !r.inst).map(r => `${esc(o.name(r.id))} (${esc(instWhy(r))})`)] })}
|
||||
${kpiTile(o, { key: 'agents', name: 'Agents working', href: '#/agents?state=working', big: agentOk.length ? sum(agentParts) : null, small: `of ${agentAll.length}`, parts: agentParts, delta: agentOk.length ? agentNote(runningOf(agentAll), waitingOn) : '', miss: [...notIn(['agents']), ...agentReads.filter(r => !r.inst).map(r => `${esc(o.name(r.id))} (${esc(instWhy(r))})`)] })}
|
||||
${kpiTile(o, { key: 'orgs', name: 'Organizations', href: '#/dashboard', big: answered, small: `of ${o.rows.length} running`, delta: o.unread.length ? andList(o.unread.map(r => `${esc(o.name(r.org))} ${offWord(r)}`)) : 'every bus answered', attrs: ' data-ws-jump="orgs"' })}
|
||||
${kpiTile(o, { key: 'needs', name: 'Needs you', href: '#/inbox', big: needsN, small: needsN === 1 ? 'item' : 'items', parts: ps.needs, delta: needsN && t?.oldestNeedsYouAt ? `oldest ${span(t.oldestNeedsYouAt)}` : 'nothing waiting', miss: notIn(SRC.needs), ...noFig })}
|
||||
${kpiTile(o, { key: 'tasks', name: 'Open tasks', href: '#/tasks', big: fig('tasks', ps.tasks), parts: ps.tasks, miss: notIn(SRC.tasks), ...noFig })}
|
||||
${kpiTile(o, { key: 'done', name: 'Done this week', href: '#/tasks', big: fig('done', ps.done), parts: ps.done, miss: notIn(SRC.done), ...noFig })}
|
||||
${kpiTile(o, { key: 'done', name: 'Done this week', href: '#/tasks?done=week', big: fig('done', ps.done), parts: ps.done, miss: notIn(SRC.done), ...noFig })}
|
||||
${kpiTile(o, { key: 'projects', name: 'Projects', href: selHref('/projects'), big: fig('projects', ps.projects), small: 'active', parts: ps.projects, delta: `${archived} archived`, miss: notIn(SRC.projects), ...noFig })}
|
||||
</div>`;
|
||||
|
||||
// SP1 (sample r2-01): one row per organization. A figure shows only when its read gave it; an
|
||||
// organization that did not answer shows its state and reason instead of figures.
|
||||
const projectsRead = readers(o, SRC.projects), inboxRead = readers(o, SRC.needs), sessionsRead = readers(o, SRC.sessions);
|
||||
const projectsRead = readers(o, SRC.projects), inboxRead = readers(o, SRC.needs);
|
||||
const none = '<span class="muted" title="not read">–</span>';
|
||||
const orgTable = o.rows.map(r => {
|
||||
const id = r.org, fromList = listRow(id), x = per.find(p => p.org === id);
|
||||
const [cls, glyph, word] = rowState(r, fromList);
|
||||
const lv = levels?.orgs?.find(v => v?.id === id);
|
||||
const count = lv?.state === 'ok' && Array.isArray(lv.base?.agents) ? lv.base.agents.length : null;
|
||||
const working = x && sessionsRead.has(id) ? num(x.sessions?.working) : null;
|
||||
const agentsCell = count === null ? none : `${plural(count, 'agent')}${working === null ? '' : ` <small class="muted">(${working} working)</small>`}`;
|
||||
// SP11: an organization whose agents were read counts the tile's rows, running and working; one
|
||||
// that was not (a stopped bus) counts the agents in its file and says nothing of what runs.
|
||||
const mineRows = agentOk.some(a => a.id === id) ? agentAll.filter(a => a.org === id) : null;
|
||||
const count = mineRows ? mineRows.length : lv?.state === 'ok' && Array.isArray(lv.base?.agents) ? lv.base.agents.length : null;
|
||||
const agentsCell = count === null ? none : `${plural(count, 'agent')}${mineRows ? ` <small class="muted">(${runningOf(mineRows)} running, ${workingOf(mineRows)} working)</small>` : ''}`;
|
||||
const projectsCell = x && projectsRead.has(id) ? plural(num(x.projects?.active), 'project') : none;
|
||||
const needsCell = x && inboxRead.has(id) ? (num(x.needsYou) ? `${num(x.needsYou)} needs you` : 'none waiting') : none;
|
||||
// #1873 SP2: a reason that only restates the state word ("the bus is not running" beside "bus stopped") is said once.
|
||||
@@ -695,7 +714,7 @@
|
||||
const why = r.state === 'ok' || !(reason || r.boot?.at) ? '' : `<span class="why">${esc(reason)}${r.boot?.at ? `${reason ? ' · ' : ''}last start ${esc(when(r.boot.at))}` : ''}</span>`;
|
||||
return `<tr class="ws-o${r.state === 'ok' ? '' : ' off'}" style="${o.style(id)}" data-ws-orgrow="${esc(id)}" data-state="${esc(r.state)}"><td class="who"><a href="${esc(orgHref(id))}/organization" data-focus-key="${esc(`orgrow:${id}`)}">${av(o.meta(id))}<span class="nm">${esc(o.name(id))}</span></a>${dot(cls, glyph, word, true)}${why}</td><td>${agentsCell}</td><td class="hide-m">${projectsCell}</td><td>${needsCell}</td></tr>`;
|
||||
}).join('');
|
||||
const orgList = o.rows.length ? `<div class="ws-tbl"><table class="ws-orgtbl" data-ws-orgs><thead><tr><th scope="col">Organization</th><th scope="col">Agents</th><th scope="col" class="hide-m">Projects</th><th scope="col">Needs you</th></tr></thead><tbody>${orgTable}</tbody></table></div>` : '<p class="ws-panel tight note" data-ws-orgs>No organizations yet</p>';
|
||||
const orgList = o.rows.length ? `<div class="ws-tbl"><table class="ws-orgtbl" data-ws-orgs><thead><tr><th scope="col">Organization</th><th scope="col">Agents</th><th scope="col" class="hide-m">Projects</th><th scope="col">Needs you</th></tr></thead><tbody>${orgTable}</tbody></table></div>` : '<p class="ws-panel tight note" data-ws-orgs>No organizations yet. <a class="ws-btn" href="#/settings/organizations?new=1" data-focus-key="home-org-new">New organization</a></p>';
|
||||
|
||||
const recent = projectRows.filter(p => p.lastActivity).slice(0, 6);
|
||||
const STATE_WORD = { working: 'has a session working', waiting: 'is waiting on you', idle: 'has an idle session', none: 'no live session' };
|
||||
@@ -950,7 +969,7 @@
|
||||
// `opts.scope` names one organization (#/orgs/<id>/agents): the same table, that organization only.
|
||||
// `opts.fireIn(org)` says from that organization's own config whether this Console decides there;
|
||||
// Fire is drawn only on the system page and only there, and posts to the row's organization (bus.js).
|
||||
const AGENT_STATE = [['working', 'Working'], ['waiting', 'Waiting on you'], ['idle', 'Idle'], ['running', 'Running'], ['not-running', 'Not running'], ['cant-launch', "Can't launch"]];
|
||||
const AGENT_STATE = [['working', 'Working'], ['waiting', 'Waiting on you'], ['idle', 'Idle'], ['running', 'Running, state unknown'], ['not-running', 'Not running'], ['cant-launch', "Can't launch"]];
|
||||
const AGENT_STATE_KEY = { working: 'working', 'waiting on you': 'waiting', idle: 'idle', running: 'running', 'not running': 'not-running', "can't launch": 'cant-launch' };
|
||||
// Each named organization's instances read, and whether this Console decides there when `fireIn` is given.
|
||||
const readInstances = (ids, fireIn) => Promise.all(ids.map(id => Promise.all([
|
||||
@@ -1044,7 +1063,8 @@
|
||||
const notRead = new Set(reads.filter(r => !r.inst).map(r => r.id));
|
||||
const chipOrgs = { ...every, rows: every.rows.map(r => (notRead.has(r.org) ? { ...r, errors: { ...(r.errors || {}), instances: 'not read' } } : r)) };
|
||||
const orgsWith = new Set(rows.map(a => a.org)).size;
|
||||
const tally = rows.length ? ` ${plural(rows.length, 'agent')}${scope ? '' : ` in ${plural(orgsWith, 'organization')}`}.` : '';
|
||||
// SP11: running and working in the Dashboard tiles' words, over the rows shown.
|
||||
const tally = rows.length ? ` ${plural(rows.length, 'agent')}${scope ? '' : ` in ${plural(orgsWith, 'organization')}`}: ${runningOf(rows)} running, ${workingOf(rows)} working.` : '';
|
||||
const sub = scope
|
||||
? `Agents in ${esc(o.name(scope))} only.${tally} <a href="#/agents" data-focus-key="agents-all">Every organization</a>`
|
||||
: `Every agent on the system, by organization.${tally} Open one to see it in its organization.`;
|
||||
@@ -1121,6 +1141,6 @@
|
||||
|
||||
// U4b: the org mark (colour and initials) for the task board (tasks-board.js).
|
||||
const orgMark = Object.freeze({ ready: ensureThemes, av, colours, initials });
|
||||
window.MosaicWorkspace = Object.freeze({ dashboard, holdLive, graftLive, agentGrid, icon, orgMark, refresh: () => refresh(), av, colours, label, state, dot, themes: ensureThemes, home, projectsPage, inboxPage, agentsPage, inboxCount, setGrouping, orgsOf, orgChip, projChip, unreadNote, setQuery, get pollMs() { return pollMs; } });
|
||||
window.MosaicWorkspace = Object.freeze({ dashboard, holdLive, graftLive, agentGrid, icon, orgMark, refresh: () => refresh(), av, colours, label, state, dot, themes: ensureThemes, home, projectsPage, inboxPage, agentsPage, inboxCount, setGrouping, orgsOf, orgChip, projChip, unreadNote, setQuery, classWord, get pollMs() { return pollMs; } });
|
||||
refresh();
|
||||
})();
|
||||
@@ -42,7 +42,7 @@ function orgs(state) {
|
||||
reads: { business: async () => ({ business: { id } }), settings: async () => ({}), busStatus: async () => ({ live: true, down: false }), queue: async () => ({ rows: [] }) },
|
||||
bus: {
|
||||
inbox: async () => [], agents: async () => [], tasks: async () => [], 'thread.claims': async () => [], messages: async () => ({ messages: [], unread: 0 }),
|
||||
instances: async () => ({ business: id, revision: 'a'.repeat(64), definitions: {}, instances: { assistant: { definition: 'assistant', vars: {} } } }),
|
||||
instances: async () => ({ business: id, revision: 'a'.repeat(64), definitions: {}, instances: { assistant: { definition: 'assistant', vars: {}, ...(state.run?.[id] ? { running: state.run[id] } : {}) } } }),
|
||||
},
|
||||
chat: {
|
||||
projects: async () => PROJECTS[id],
|
||||
@@ -51,7 +51,7 @@ function orgs(state) {
|
||||
shares: async () => [],
|
||||
messages: async ({ thread }) => [
|
||||
...(MESSAGES[thread] ? [{ id: thread.replace(/^2/, '3'), from_role: 'assistant', body: MESSAGES[thread], at: '2026-11-15T10:00:00.000Z' }] : []),
|
||||
...state.sent.filter(s => s.org === id && s.thread === thread).map(s => ({ id: s.id, from_role: 'human', body: s.body, at: '2026-11-15T10:01:00.000Z' })),
|
||||
...(state.hideSent ? [] : state.sent).filter(s => s.org === id && s.thread === thread).map(s => ({ id: s.id, from_role: 'human', body: s.body, at: '2026-11-15T10:01:00.000Z' })),
|
||||
...(state.replies || []).filter(r => r.org === id && r.thread === thread).map((r, n) => ({ id: `50000000-0000-4000-8000-${String(n + 1).padStart(12, '0')}`, from_role: 'assistant', body: r.body, at: '2026-11-15T10:02:00.000Z' })),
|
||||
],
|
||||
send: async ({ thread, body }) => {
|
||||
@@ -304,6 +304,26 @@ test('SP7: a thread link on a project page opens the thread as a pane on the Ass
|
||||
assert.equal(await b.evaluate('location.hash'), `#/orgs/beta/projects/threads/${T.b1}`);
|
||||
});
|
||||
|
||||
// SP11 (#1873): Resume assistant shows only when the organization's instances read says the thread's
|
||||
// agent runs no process (paused or stopped). An agent the read does not say about keeps the control.
|
||||
test('Resume assistant shows only in a pane whose agent the instances read says is not running', async t => {
|
||||
const state = { down: new Set(), sent: [], created: [], run: { alpha: ['run-1'], beta: [] } };
|
||||
const { b, wait } = await open(t, { hash: `#/assistant?open=alpha:${T.a1},beta:${T.b1},home:${T.h1}`, state });
|
||||
const resume = sel => `!!${PANE(sel)}.querySelector('button.pane-resume')`;
|
||||
await wait(`${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)} && ${ready(`home:${T.h1}`)}`);
|
||||
await wait(resume(`beta:${T.b1}`));
|
||||
assert.equal(await b.evaluate(resume(`alpha:${T.a1}`)), false, 'a running assistant offers no resume');
|
||||
assert.equal(await b.evaluate(resume(`home:${T.h1}`)), true, 'a read that does not say keeps the control');
|
||||
// Alpha's assistant stops: the next read shows the control.
|
||||
state.run.alpha = [];
|
||||
await b.evaluate('window.MosaicAssistant.refresh()');
|
||||
await wait(resume(`alpha:${T.a1}`));
|
||||
// Beta's starts again: its control goes.
|
||||
state.run.beta = ['run-2'];
|
||||
await b.evaluate('window.MosaicAssistant.refresh()');
|
||||
await wait(`!${resume(`beta:${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 });
|
||||
@@ -331,8 +351,11 @@ test('SP7: with three open, a fourth thread replaces the first pane with no unse
|
||||
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.
|
||||
// Closing a pane clears the reason; the thread then opens. SP11: the pane holds a message, so the
|
||||
// close asks first.
|
||||
await b.evaluate(`window.__asked = []; window.confirm = m => (window.__asked.push(m), true)`);
|
||||
await b.evaluate(`${PANE(`home:${T.h1}`)}.querySelector('button[data-assist-close]').click()`);
|
||||
assert.equal(await b.evaluate('window.__asked.length'), 1);
|
||||
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}`);
|
||||
@@ -351,6 +374,115 @@ test('SP7: each pane names its own organization\'s Messages page, and the bar no
|
||||
assert.doesNotMatch(await b.evaluate(`document.querySelector('.assist-bar').innerText`), /Messages/);
|
||||
});
|
||||
|
||||
// SP11 (#1873): a pane's unsent message is kept per thread while the tab is open. Leaving the
|
||||
// Assistant page, by the sidebar or by a pane's Full view into another organization, keeps it, and the
|
||||
// pane shows it again when it opens. Closing a pane that holds one asks first.
|
||||
test('SP11: an unsent pane message survives leaving the Assistant page, through Full view too, and closing a pane with one asks first', async t => {
|
||||
const state = { down: new Set(), sent: [], created: [] };
|
||||
const a1 = `alpha:${T.a1}`, b1 = `beta:${T.b1}`, both = `#/assistant?open=${a1},${b1}`;
|
||||
const { b, wait } = await open(t, { hash: both, state });
|
||||
await wait(`${ready(a1)} && ${ready(b1)} && !${field(a1)}.disabled && !${field(b1)}.disabled`);
|
||||
await type(b, a1, 'alpha keep');
|
||||
await type(b, b1, 'beta keep');
|
||||
// Away to Tasks and back.
|
||||
await b.evaluate(`location.hash = '#/tasks'`);
|
||||
await wait(`!document.querySelector('[data-assist-pane]') && !!document.querySelector('[data-tasks-board]')`);
|
||||
await b.evaluate(`location.hash = ${JSON.stringify(both)}`);
|
||||
await wait(`${field(a1)}?.value === 'alpha keep' && ${field(b1)}?.value === 'beta keep'`);
|
||||
// Away through Beta's Full view: no discard question, and the message is still there after.
|
||||
await b.evaluate(`${PANE(b1)}.querySelector('a[data-assist-pane-full]').click()`);
|
||||
await wait(`location.hash.startsWith('#/orgs/beta/') && !document.querySelector('[data-assist-pane]')`);
|
||||
assert.equal(await b.evaluate(`!!document.querySelector('dialog[open]')`), false, 'leaving asks nothing: the message is kept');
|
||||
await b.evaluate(`location.hash = ${JSON.stringify(both)}`);
|
||||
await wait(`${field(a1)}?.value === 'alpha keep' && ${field(b1)}?.value === 'beta keep'`);
|
||||
// Closing a pane with an unsent message asks; saying no keeps the pane.
|
||||
await b.evaluate(`window.__asked = []; window.confirm = m => (window.__asked.push(m), false)`);
|
||||
await b.evaluate(`${PANE(a1)}.querySelector('button[data-assist-close]').click()`);
|
||||
assert.deepEqual(await b.evaluate('window.__asked'), ['Close Launch email? Its unsent message is lost.']);
|
||||
assert.deepEqual(await b.evaluate(panes), [a1, b1]);
|
||||
// Saying yes closes it and drops the message: the thread opens again empty.
|
||||
await b.evaluate(`window.confirm = m => (window.__asked.push(m), true)`);
|
||||
await b.evaluate(`${PANE(a1)}.querySelector('button[data-assist-close]').click()`);
|
||||
await wait(`document.querySelectorAll('[data-assist-pane]').length === 1`);
|
||||
await follow(b, `#/orgs/alpha/projects/threads/${T.a1}`);
|
||||
await wait(`${ready(a1)} && !!${field(a1)} && !${field(a1)}.disabled`);
|
||||
assert.equal(await b.evaluate(`${field(a1)}.value`), '');
|
||||
// A pane with nothing typed closes with no question.
|
||||
await b.evaluate(`${PANE(a1)}.querySelector('button[data-assist-close]').click()`);
|
||||
await wait(`document.querySelectorAll('[data-assist-pane]').length === 1`);
|
||||
assert.equal(await b.evaluate('window.__asked.length'), 2);
|
||||
assert.deepEqual(state.sent, []);
|
||||
});
|
||||
|
||||
// SP11 review: signing out drops every kept pane message, so the next person to sign in on this tab
|
||||
// never sees text typed before, after a reload too.
|
||||
test('SP11: signing out drops kept pane messages, and a reload after it opens the pane empty', async t => {
|
||||
const state = { down: new Set(), sent: [], created: [] };
|
||||
const a1 = `alpha:${T.a1}`, hash = `#/assistant?open=${a1}`;
|
||||
const { b, base, wait } = await open(t, { hash, state });
|
||||
await wait(`${ready(a1)} && !${field(a1)}.disabled`);
|
||||
await type(b, a1, 'private note');
|
||||
await b.evaluate(`location.hash = '#/tasks'`);
|
||||
await wait(`!document.querySelector('[data-assist-pane]')`);
|
||||
assert.match(await b.evaluate(`sessionStorage.getItem('mosaic-assistant-drafts') || ''`), /private note/);
|
||||
await b.evaluate(`location.hash = ${JSON.stringify(hash)}`);
|
||||
await wait(`${field(a1)}?.value === 'private note'`);
|
||||
await b.evaluate(`document.dispatchEvent(new Event('mosaic-signed-out')), true`);
|
||||
await wait(`document.body.classList.contains('signed-out')`);
|
||||
assert.equal(await b.evaluate(`sessionStorage.getItem('mosaic-assistant-drafts')`), null);
|
||||
// A query string makes it a real load; the same address with only a hash would not reload.
|
||||
await b.navigate(base + '?again=1' + hash);
|
||||
await wait(`!document.body.classList.contains('signed-out') && ${ready(a1)} && !!${field(a1)} && !${field(a1)}.disabled`);
|
||||
assert.equal(await b.evaluate(`${field(a1)}.value`), '');
|
||||
assert.equal(await b.evaluate(`sessionStorage.getItem('mosaic-assistant-drafts')`), null);
|
||||
assert.deepEqual(state.sent, []);
|
||||
});
|
||||
|
||||
// SP11 review: the Sign out form is a plain POST that unloads the page; its submit drops the kept
|
||||
// messages and the pagehide it causes keeps nothing, so a sign-in after it opens the pane empty.
|
||||
test('SP11: the Sign out form drops kept pane messages, and the page it unloads keeps none', async t => {
|
||||
const state = { down: new Set(), sent: [], created: [] };
|
||||
const a1 = `alpha:${T.a1}`, hash = `#/assistant?open=${a1}`;
|
||||
const { b, base, wait } = await open(t, { hash, state });
|
||||
await wait(`${ready(a1)} && !${field(a1)}.disabled`);
|
||||
await type(b, a1, 'private note');
|
||||
await b.evaluate(`location.hash = '#/tasks'`);
|
||||
await wait(`!document.querySelector('[data-assist-pane]')`);
|
||||
await b.evaluate(`location.hash = ${JSON.stringify(hash)}`);
|
||||
await wait(`${field(a1)}?.value === 'private note'`);
|
||||
await b.evaluate(`document.getElementById('sign-out').requestSubmit(), true`);
|
||||
// The POST unloads the page; poll from here, since the old page's context goes away under it.
|
||||
let left = false;
|
||||
for (let i = 0; i < 200 && !left; i++) { try { left = await b.evaluate(`location.pathname === '/auth/logout'`); } catch { /* navigating */ } if (!left) await new Promise(r => setTimeout(r, 30)); }
|
||||
assert.equal(left, true, 'the Sign out form posted');
|
||||
await b.navigate(base + '?again=1' + hash);
|
||||
await wait(`${ready(a1)} && !!${field(a1)} && !${field(a1)}.disabled`);
|
||||
assert.equal(await b.evaluate(`${field(a1)}.value`), '');
|
||||
assert.equal(await b.evaluate(`sessionStorage.getItem('mosaic-assistant-drafts')`), null);
|
||||
assert.deepEqual(state.sent, []);
|
||||
});
|
||||
|
||||
// SP11 (#1873): a pane whose message was sent but not yet shown in the conversation is not free: a
|
||||
// fourth thread replaces the first pane that holds nothing, and that pane only once its receipt is in.
|
||||
test('SP11: a pane whose send still waits for its receipt is not replaced by a fourth thread', async t => {
|
||||
const state = { down: new Set(), sent: [], created: [], hideSent: true };
|
||||
const a1 = `alpha:${T.a1}`, b1 = `beta:${T.b1}`, a2 = `alpha:${T.a2}`, h1 = `home:${T.h1}`;
|
||||
const { b, wait } = await open(t, { hash: `#/assistant?open=${a1},${b1},${a2}`, state });
|
||||
await wait(`${ready(a1)} && ${ready(b1)} && ${ready(a2)} && !${field(a1)}.disabled`);
|
||||
await b.evaluate(`(() => { const el = ${field(a1)}; el.value = 'in flight'; el.dispatchEvent(new Event('input', { bubbles: true })); el.form.requestSubmit(); })()`);
|
||||
await wait(`/Accepted message/.test(${PANE(a1)}.innerText) && ${field(a1)}.value === ''`);
|
||||
await follow(b, `#/orgs/home/projects/threads/${T.h1}`);
|
||||
await wait(ready(h1));
|
||||
assert.deepEqual(await b.evaluate(panes), [a1, h1, a2], 'the waiting pane stays; the first free one gives way');
|
||||
// The message shows: the receipt is in and the pane is free again.
|
||||
state.hideSent = false;
|
||||
await b.evaluate('window.MosaicAssistant.refresh()');
|
||||
await wait(`/Message shown in this conversation/.test(${PANE(a1)}.innerText)`);
|
||||
await follow(b, `#/orgs/beta/projects/threads/${T.b1}`);
|
||||
await wait(ready(b1));
|
||||
assert.deepEqual(await b.evaluate(panes), [b1, h1, a2]);
|
||||
});
|
||||
|
||||
// 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 => {
|
||||
|
||||
@@ -16,7 +16,9 @@ const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++)
|
||||
const at = minutesAgo => new Date(Date.now() - minutesAgo * 60000).toISOString();
|
||||
|
||||
// fail: { org: [read names] } makes those reads refuse; `none` empties the organization list.
|
||||
function fixture({ fail = {}, none = false } = {}) {
|
||||
// split: { org: agents } is what that organization's own bus agents read answers, while the
|
||||
// cross-organization sessions read still answers the fixture's agents.
|
||||
function fixture({ fail = {}, none = false, split = {} } = {}) {
|
||||
const LIST = none ? [] : [
|
||||
{ id: 'acme', label: 'Acme Studio', theme: 'cobalt', busHealthy: true },
|
||||
{ id: 'beta', label: 'Beta Works', theme: 'grove', busHealthy: true },
|
||||
@@ -63,7 +65,7 @@ function fixture({ fail = {}, none = false } = {}) {
|
||||
busHealthy: true,
|
||||
reads: { business: async () => ({ business: { id, human: 'jason', projects: DATA[id].projects.map(p => p.repo), appearance: { theme: org.theme } } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
|
||||
bus: {
|
||||
messages: async () => ({ messages: [], unread: 0 }), agents: async () => DATA[id].agents, 'thread.claims': async () => [], inbox: async () => [], tasks: async () => [],
|
||||
messages: async () => ({ messages: [], unread: 0 }), agents: async () => { if (fail[id]?.includes('agents')) throw Object.assign(new Error('off'), { code: 'channel-disabled' }); return split[id] ?? DATA[id].agents; }, 'thread.claims': async () => [], inbox: async () => [], tasks: async () => [],
|
||||
instances: () => (fail[id]?.includes('instances') ? Promise.reject(Object.assign(new Error('off'), { code: 'bus-unavailable' })) : Promise.resolve({ revision: 'a'.repeat(64), definitions: {}, instances: DATA[id].instances })),
|
||||
},
|
||||
chat: { projects: async () => DATA[id].projects, threads: async () => [] },
|
||||
@@ -72,8 +74,8 @@ function fixture({ fail = {}, none = false } = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
async function setup(t, { viewport = [1440, 900], hash = '#/dashboard', fail, none } = {}) {
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ fail, none }), log: () => {} });
|
||||
async function setup(t, { viewport = [1440, 900], hash = '#/dashboard', fail, none, split } = {}) {
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ fail, none, split }), log: () => {} });
|
||||
t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
await b.viewport(...viewport);
|
||||
@@ -110,7 +112,7 @@ test('The Dashboard tiles add both answering organizations\' numbers, one row, e
|
||||
assert.equal(links.orgs, '#/dashboard');
|
||||
assert.equal(links.needs, '#/inbox');
|
||||
assert.equal(links.tasks, '#/tasks');
|
||||
assert.equal(links.done, '#/tasks');
|
||||
assert.equal(links.done, '#/tasks?done=week');
|
||||
assert.match(links.projects, /^#\/orgs\/[a-z0-9._-]+\/projects$/);
|
||||
// No sample numbers and no trend line: no tile draws an svg sparkline.
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("[data-ws-tiles] svg:not(.i)").length'), 0);
|
||||
@@ -143,6 +145,11 @@ test('Following a tile lands on its page showing what the tile counted', async t
|
||||
await wait('location.hash === "#/inbox" && document.querySelectorAll("[data-ws-item]").length === 3');
|
||||
await b.evaluate('location.hash = "#/dashboard"');
|
||||
await wait(homeReady);
|
||||
// SP11: Done this week lands on the board showing the one task it counted, beta's Logo.
|
||||
await b.evaluate('document.querySelector("[data-ws-tile=done]").click()');
|
||||
await wait('location.hash === "#/tasks?done=week" && JSON.stringify([...document.querySelectorAll("[data-tasks-board] [data-tb-task]")].map(e => e.dataset.tbTask)) === JSON.stringify(["beta|local:9"])');
|
||||
await b.evaluate('location.hash = "#/dashboard"');
|
||||
await wait(homeReady);
|
||||
// The Organizations tile's list is this page's own table: following it keeps the page and moves focus there.
|
||||
await b.evaluate('document.querySelector("[data-ws-tile=orgs]").click()');
|
||||
await wait('document.activeElement?.closest?.("[data-ws-orgs-h]")');
|
||||
@@ -165,10 +172,22 @@ test('When no answering organization could read its agents, the Agents tile says
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-ws-tile=agents]").getAttribute("href")'), '#/agents?state=working');
|
||||
});
|
||||
|
||||
test('With no organizations there is nothing to count, so no tile shows a zero', async t => {
|
||||
// With no organization the workspace pages are not drawn; the Console's own board keeps its dashes.
|
||||
const { b, wait } = await setup(t, { none: true });
|
||||
await wait('document.querySelector("#dashboard-kpis .kpi strong")');
|
||||
// SP11 (#1873): with no organizations, #/dashboard is the Dashboard, not Not found. It says there is
|
||||
// nothing to count and offers New organization, which opens the Organizations form at its ID field.
|
||||
// #/ stays the Console's own board, with its dashes.
|
||||
test('With no organizations the Dashboard says so, shows no tile, and offers New organization', async t => {
|
||||
const { b, wait, text } = await setup(t, { none: true });
|
||||
await wait('document.querySelector("[data-ws-home] h1")');
|
||||
assert.equal(await text('#bus-view h1'), 'Dashboard');
|
||||
assert.doesNotMatch(await text('#bus-view'), /Not found/);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll(".ws-tile, .ws-big").length'), 0);
|
||||
assert.match(await text('[data-ws-home] [data-ws-tiles]'), /^No organizations yet, so there is nothing to count\./);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-ws-home] [data-ws-orgs] a")?.getAttribute("href") ?? null'), '#/settings/organizations?new=1');
|
||||
assert.equal(await text('[data-ws-home] [data-ws-orgs] a'), 'New organization');
|
||||
await b.evaluate('document.querySelector("[data-ws-home] [data-ws-orgs] a").click()');
|
||||
await wait('document.activeElement?.id === "org-new-id"');
|
||||
await b.evaluate('location.hash = "#/"');
|
||||
await wait('document.querySelector("#dashboard-kpis .kpi strong") && !document.querySelector("[data-ws-home]")');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll(".ws-tile, .ws-big").length'), 0);
|
||||
assert.deepEqual(await b.evaluate('[...new Set([...document.querySelectorAll("#dashboard-kpis .kpi strong")].map(e => e.textContent.trim()))]'), ['–']);
|
||||
});
|
||||
@@ -183,7 +202,7 @@ test('An organization page leads with its own tiles, each linking to the system
|
||||
assert.equal(await b.evaluate(big('[data-org-tiles]', 'tasks')), '2');
|
||||
assert.equal(await b.evaluate(big('[data-org-tiles]', 'needs')), '2 items');
|
||||
assert.equal(await b.evaluate(big('[data-org-tiles]', 'done')), '1');
|
||||
assert.deepEqual(await b.evaluate(hrefs('[data-org-tiles]')), { agents: '#/agents?org=beta&state=working', tasks: '#/tasks?org=beta', needs: '#/inbox?org=beta', done: '#/tasks?org=beta' });
|
||||
assert.deepEqual(await b.evaluate(hrefs('[data-org-tiles]')), { agents: '#/agents?org=beta&state=working', tasks: '#/tasks?org=beta', needs: '#/inbox?org=beta', done: '#/tasks?org=beta&done=week' });
|
||||
// The empty KPI and metric placeholders are gone; there is one tile row.
|
||||
assert.doesNotMatch(await text('[data-org-dashboard]'), /No KPIs defined|No metrics recorded yet/);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("[data-org-dashboard] .ws-tiles, [data-org-dashboard] [data-org-tiles]").length'), 1);
|
||||
@@ -200,6 +219,47 @@ test('An organization page leads with its own tiles, each linking to the system
|
||||
await wait('location.hash === "#/agents?org=acme&state=working" && JSON.stringify([...document.querySelectorAll("[data-ws-agent]")].map(r => r.dataset.wsAgent)) === JSON.stringify(["acme:coder"])');
|
||||
});
|
||||
|
||||
// SP11 (#1873): "working" and "running" each name what they count. Running is an agent with a live
|
||||
// process, whatever it is doing; working is a running agent whose role session is working. The tile,
|
||||
// the Dashboard organization rows and the sessions heading count the same agent rows.
|
||||
const delta = (scope, key) => `document.querySelector(${JSON.stringify(`${scope} [data-ws-tile=${key}] .ws-delta:not(.ws-miss)`)})?.textContent.trim() ?? null`;
|
||||
const rowAgents = org => `document.querySelector("[data-ws-orgrow=${org}] td:nth-child(2)")?.innerText.replace(/\\s+/g, " ").trim() ?? null`;
|
||||
test('Agent counts say running and working, and count the same agents on the tiles, the organization rows and the sessions heading', async t => {
|
||||
const { b, wait, text } = await setup(t);
|
||||
await wait(homeReady);
|
||||
// acme's coder runs and works; beta's pm runs and waits on you; chief and writer run nothing.
|
||||
assert.equal(await b.evaluate(big('[data-ws-home]', 'agents')), '1 of 4');
|
||||
assert.equal(await b.evaluate(delta('[data-ws-home]', 'agents')), '2 running, 1 waiting on you');
|
||||
assert.equal(await b.evaluate(rowAgents('acme')), '2 agents (1 running, 1 working)');
|
||||
assert.equal(await b.evaluate(rowAgents('beta')), '2 agents (1 running, 0 working)');
|
||||
await b.evaluate('location.hash = "#/orgs/beta"');
|
||||
await wait('document.querySelector("[data-org-dashboard] [data-org-tiles] [data-ws-tile=agents] .ws-big")');
|
||||
assert.equal(await b.evaluate(big('[data-org-tiles]', 'agents')), '0 of 2');
|
||||
assert.equal(await b.evaluate(delta('[data-org-tiles]', 'agents')), '1 running, 1 waiting on you');
|
||||
assert.match(await text('[data-org-dashboard] .ws-split'), /Active agent sessions\s*1 running, 0 working/);
|
||||
await b.evaluate('location.hash = "#/orgs/acme"');
|
||||
await wait(`location.hash.startsWith("#/orgs/acme") && (${big('[data-org-tiles]', 'agents')}) === "1 of 2"`);
|
||||
assert.equal(await b.evaluate(delta('[data-org-tiles]', 'agents')), '1 running');
|
||||
assert.match(await text('[data-org-dashboard] .ws-split'), /Active agent sessions\s*1 running, 1 working/);
|
||||
});
|
||||
|
||||
test('An organization page counts working from one read: the sessions heading and rows follow the tile when the two bus reads disagree', async t => {
|
||||
// acme's own agents read says the coder is idle; the sessions read the tile counts says working.
|
||||
const split = { acme: [{ role: 'coder', holder_run: 'run-1', harness: 'claude', state: 'idle', at: at(40) }] };
|
||||
const { b, wait, text } = await setup(t, { hash: '#/orgs/acme', split });
|
||||
await wait(`document.querySelector("[data-org-dashboard]") && (${big('[data-org-tiles]', 'agents')}) === "1 of 2"`);
|
||||
assert.match(await text('[data-org-dashboard] .ws-split'), /Active agent sessions\s*1 running, 1 working/);
|
||||
assert.match(await text('[data-org-dashboard] .ws-split tbody'), /coder[\s\S]*working/);
|
||||
});
|
||||
|
||||
test('When neither bus read gives agent activity, the sessions heading says working was not read, never a figure', async t => {
|
||||
const { b, wait, text } = await setup(t, { hash: '#/orgs/acme', fail: { acme: ['agents'] } });
|
||||
await wait('document.querySelector("[data-org-dashboard] [data-org-tiles] [data-ws-tile=agents]")');
|
||||
assert.match(await text('[data-org-tiles] [data-ws-tile=agents]'), /Not read/);
|
||||
assert.match(await text('[data-org-dashboard] .ws-split'), /Active agent sessions\s*1 running, working not read/);
|
||||
assert.doesNotMatch(await text('[data-org-dashboard] .ws-split'), /\d+ working/);
|
||||
});
|
||||
|
||||
test('A stopped organization\'s page shows its tiles as not read, still linking to its filtered pages', async t => {
|
||||
const { b, wait, text } = await setup(t, { hash: '#/orgs/gamma' });
|
||||
await wait('document.querySelector("[data-org-dashboard] [data-org-tiles]")');
|
||||
|
||||
@@ -17,7 +17,7 @@ const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++)
|
||||
const at = minutesAgo => new Date(Date.now() - minutesAgo * 60000).toISOString();
|
||||
|
||||
const HIRE = '0f3c1a2b-4d5e-4f60-8a7b-9c0d1e2f3a4b';
|
||||
function fixture({ empty = false } = {}) {
|
||||
function fixture({ empty = false, unheld = false } = {}) {
|
||||
const LIST = [
|
||||
{ id: 'acme', label: 'Acme Studio', theme: 'cobalt', busHealthy: true },
|
||||
{ id: 'beta', label: 'Beta Works', theme: 'grove', busHealthy: true },
|
||||
@@ -38,6 +38,8 @@ function fixture({ empty = false } = {}) {
|
||||
},
|
||||
};
|
||||
if (empty) for (const d of Object.values(DATA)) { d.instances = {}; d.agents = []; }
|
||||
// `unheld`: a live designer run that holds no role session, so the bus reports no state for it.
|
||||
if (unheld) DATA.acme.instances.designer = { definition: 'designer', running: ['run-5'] };
|
||||
const value = (id, name) => Promise.resolve(DATA[id]?.[name] ?? (['usage', 'limits'].includes(name) ? {} : []));
|
||||
const opts = id => ({
|
||||
bus: { inbox: () => value(id, 'inbox'), agents: () => value(id, 'agents'), 'thread.claims': () => value(id, 'claims'), tasks: () => value(id, 'tasks'), usage: () => value(id, 'usage'), limits: () => value(id, 'limits'), decisions: () => value(id, 'decisions') },
|
||||
@@ -90,8 +92,8 @@ const STUB = `(() => {
|
||||
};
|
||||
})();`;
|
||||
|
||||
async function setup(t, { viewport = [1440, 900], hash = '#/agents', empty = false, stub = false, noDecide = [] } = {}) {
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ empty }), log: () => {} });
|
||||
async function setup(t, { viewport = [1440, 900], hash = '#/agents', empty = false, unheld = false, stub = false, noDecide = [] } = {}) {
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ empty, unheld }), log: () => {} });
|
||||
t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
await b.viewport(...viewport);
|
||||
@@ -132,6 +134,19 @@ test('#/agents lists both organizations\' agents in one table, each row with its
|
||||
assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"] a[data-ws-open]\')?.getAttribute("href")'), '#/orgs/beta/agents?agent=pm');
|
||||
});
|
||||
|
||||
// SP11 (#1873): the page says how many of its rows run and how many work, in the words the Dashboard
|
||||
// tiles use. A live agent with no reported state is "Running, state unknown", not a bare "Running"
|
||||
// that reads as every running agent.
|
||||
test('#/agents counts its running and working agents, and names a running agent with no reported state', async t => {
|
||||
const { b, wait, text } = await setup(t, { unheld: true });
|
||||
await wait(are(['acme:chief', 'acme:coder', 'acme:designer', 'beta:pm', 'beta:research-2']));
|
||||
assert.match(await text('[data-ws-agents] .sub'), /^Every agent on the system, by organization\. 5 agents in 2 organizations: 3 running, 1 working\./);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-ws-astate=running]")?.innerText.replace(/\\s+/g, " ").trim()'), 'Running, state unknown 1');
|
||||
await b.evaluate('document.querySelector("[data-ws-astate=working]").click()');
|
||||
await wait(are(['acme:coder']));
|
||||
assert.match(await text('[data-ws-agents] .sub'), /1 agent in 1 organization: 1 running, 1 working\./);
|
||||
});
|
||||
|
||||
test('the organization and state filters live in the hash and survive a reload', async t => {
|
||||
const { b, base, wait } = await setup(t);
|
||||
await wait(are(ALL));
|
||||
|
||||
@@ -18,7 +18,7 @@ const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++)
|
||||
const at = minutesAgo => new Date(Date.now() - minutesAgo * 60000).toISOString();
|
||||
const D = n => String(n).repeat(64);
|
||||
|
||||
function fixture({ empty = false, archived = [] } = {}) {
|
||||
function fixture({ empty = false, archived = [], done = false } = {}) {
|
||||
const LIST = [
|
||||
{ id: 'acme', label: 'Acme Studio', theme: 'cobalt', busHealthy: true },
|
||||
{ id: 'beta', label: 'Beta Works', theme: 'grove', busHealthy: true },
|
||||
@@ -33,9 +33,11 @@ function fixture({ empty = false, archived = [] } = {}) {
|
||||
beta: [{ n: 2, title: 'Price page', state: 'in-progress', group: 'shop', assignees: [] }, { n: 3, title: 'Spring banner', state: 'todo', group: 'ads', assignees: [] }],
|
||||
};
|
||||
if (empty) { STORE.acme = []; STORE.beta = []; }
|
||||
// `done`: one task finished two days ago and one ten days ago; only the first is done this week.
|
||||
if (done) { STORE.acme.push({ n: 4, title: 'Old logo', state: 'done', group: 'site', assignees: [], ago: 60 * 24 * 10 }); STORE.beta.push({ n: 5, title: 'Launch email', state: 'done', group: 'shop', assignees: [], ago: 60 * 24 * 2 }); }
|
||||
for (const [org, list] of Object.entries(PROJECTS)) for (const p of list) p.archived = archived.includes(`${org}:${p.id}`);
|
||||
const tasks = id => (STORE[id] || []).map(t => ({ source: 'store', task_ref: `task:${t.n}`, at: at(10), priority: 0, digest: D(t.n), requirement: 'REQ-WEB-1',
|
||||
fields: { title: t.title, state: t.state, group: t.group, assignees: t.assignees, due_date: null } }));
|
||||
const tasks = id => (STORE[id] || []).map(t => ({ source: 'store', task_ref: `task:${t.n}`, at: at(t.ago ?? 10), priority: 0, digest: D(t.n), requirement: 'REQ-WEB-1',
|
||||
fields: { title: t.title, state: t.state, group: t.group, assignees: t.assignees, due_date: null, ...(t.state === 'done' ? { done: true } : {}) } }));
|
||||
const writes = [];
|
||||
const taskWrite = org => async (verb, args) => { writes.push([org, verb, args]); return { task_ref: args.task_ref, digest: D(9) }; };
|
||||
const opts = id => ({
|
||||
@@ -81,8 +83,8 @@ const STUB = noWrite => `window.__noWrite = ${JSON.stringify(noWrite)};
|
||||
};
|
||||
})();`;
|
||||
|
||||
async function setup(t, { viewport = [1440, 900], hash = '#/tasks', empty = false, noWrite = [], archived = [] } = {}) {
|
||||
const orgs = fixture({ empty, archived });
|
||||
async function setup(t, { viewport = [1440, 900], hash = '#/tasks', empty = false, noWrite = [], archived = [], done = false } = {}) {
|
||||
const orgs = fixture({ empty, archived, done });
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: orgs, send: async () => ({}), log: () => {} });
|
||||
t.after(() => close(server));
|
||||
const b = await browser({ machine: false, args: ['--disable-extensions'] }); t.after(() => b.close());
|
||||
@@ -118,6 +120,26 @@ test('#/tasks shows both organizations\' tasks on one board, each card with its
|
||||
assert.equal(await b.evaluate('document.querySelector("#bus-view .s1-stale")'), null);
|
||||
});
|
||||
|
||||
// SP11 (#1873): the Dashboard's Done this week tile opens the board on the tasks it counted: done, and
|
||||
// changed in the last seven days. The bus keeps no time a task was done, so the filter says it reads
|
||||
// the last change, the time the tile counts by. The filter lives in the address and clears.
|
||||
test('#/tasks?done=week shows only the tasks Done this week counted, says what it counts, and clears', async t => {
|
||||
const { b, base, wait, text } = await setup(t, { hash: '#/tasks?done=week', done: true });
|
||||
await wait(`${cards} === ${JSON.stringify(JSON.stringify(['beta|task:5']))}`);
|
||||
assert.match(await text('[data-tb-done]'), /^Done this week\s+×$/);
|
||||
assert.match(await b.evaluate('document.querySelector("[data-tb-done]").getAttribute("aria-label")'), /^Clear Done this week: done tasks last changed in the past 7 days$/);
|
||||
assert.match(await text('[data-tasks-board] .tb-count'), /^Showing 1 of 5 tasks\. Done this week counts done tasks by their last change; the bus records no time a task was done\.$/);
|
||||
// It survives a reload and joins the organization filter.
|
||||
await b.navigate(`${base}/#/tasks?org=acme&done=week`);
|
||||
await wait('document.querySelector("[data-tb-done]") && document.querySelector("[data-tasks-board] .ws-panel")?.textContent.includes("No tasks match these filters")');
|
||||
await b.evaluate('location.hash = "#/tasks?done=week"');
|
||||
await wait(`${cards} === ${JSON.stringify(JSON.stringify(['beta|task:5']))}`);
|
||||
// Clearing it drops it from the address and shows every task again.
|
||||
await b.evaluate('document.querySelector("[data-tb-done]").click()');
|
||||
await wait(`location.hash === "#/tasks" && ${cards} === ${JSON.stringify(JSON.stringify(['acme|task:1', 'acme|task:4', 'beta|task:2', 'beta|task:3', 'beta|task:5']))}`);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-tb-done]")'), null);
|
||||
});
|
||||
|
||||
test('the organization and project filters live in the address and survive a reload', async t => {
|
||||
const { b, base, wait } = await setup(t);
|
||||
await wait(`${cards} === ${ALL}`);
|
||||
|
||||
@@ -211,7 +211,8 @@ test('each header control is present, reload is disabled, and restart and stop a
|
||||
stop: stop ? [stop.textContent, stop.disabled, stop.closest('.bus-restart') === live.closest('.bus-restart')] : null, stopOff: stopOff ? [stopOff.textContent, stopOff.disabled, !!stopOff.title] : null,
|
||||
reload: [reload.textContent, reload.disabled, reload.title, document.getElementById(reload.getAttribute('aria-describedby'))?.textContent] };
|
||||
})()`));
|
||||
const RELOAD = ['Reload organization file', true, 'Reload arrives with the cross-org update', 'Restart is a trailed human action; reload arrives with the cross-org update.'];
|
||||
// SP11 (#1873): the line beside the disabled reload says in plain words how a change to the file loads.
|
||||
const RELOAD = ['Reload organization file', true, 'Reloading without a restart is not available yet', 'The bus reads the organization file when it starts. To load a change to the file, restart the bus.'];
|
||||
// The owner on a Console started with --send gets console-restart.js's restart button.
|
||||
// Stop bus sits beside it, in the same control, and is one press like Restart bus.
|
||||
assert.deepEqual(await header(owner), { live: ['Restart bus', false], off: null, stop: ['Stop bus', false, true], stopOff: null, reload: RELOAD });
|
||||
|
||||
@@ -45,7 +45,7 @@ export async function w15Fixture(t, { scenario = {}, slice = 'shell' } = {}) {
|
||||
if(/^\\/api\\/chat\\/threads\\/[^/]+\\/sessions$/.test(path)) return data({rows:[{id:ids.session,name:'Default',default:true}]});
|
||||
if(/^\\/api\\/chat\\/threads\\/[^/]+\\/messages(?:\\?.*)?$/.test(path)) return data({rows:[],after:null,hasMore:false});
|
||||
if(path==='/api/chat/threads' || /^\\/api\\/chat\\/threads\\?(shared=true|project=)/.test(path)) return data({rows:[{id:ids.thread,project:ids.project,name:'Project conversation',instance:'assistant'}]});
|
||||
if(/^\\/api\\/bus\\/(inbox|messages|agents|tasks|queue|trail)(\\?|$)/.test(path)) return data({rows:[],unread:0});
|
||||
if(/^\\/api\\/bus\\/(inbox|messages|agents|instances|tasks|queue|trail)(\\?|$)/.test(path)) return data({rows:[],unread:0});
|
||||
__unexpected.push(method+' '+path); return new Response(JSON.stringify({error:'fixture-unexpected-api'}),{status:403,headers:{'content-type':'application/json'}});
|
||||
};
|
||||
})();` });
|
||||
|
||||
@@ -14,7 +14,8 @@ const at = minutesAgo => new Date(Date.now() - minutesAgo * 60000).toISOString()
|
||||
// acme and beta answer; gamma's bus is stopped. Decision ids are long so a shortened id would show.
|
||||
const ACME_DECISION = 'dec-acme-7f3c9a21-5b8e-4d1f-9c2a-0e6b7d8f9a10';
|
||||
const BETA_DECISION = 'dec-beta-1a2b3c4d-5e6f-4a1b-8c9d-0e1f2a3b4c5d';
|
||||
function fixture({ pollMs, fail = {}, down = [] } = {}) {
|
||||
// messages: { org: rows } is what that organization's bus messages read answers.
|
||||
function fixture({ pollMs, fail = {}, down = [], messages = {} } = {}) {
|
||||
const LIST = [
|
||||
{ id: 'acme', label: 'Acme Studio', theme: 'cobalt', busHealthy: true },
|
||||
{ id: 'beta', label: 'Beta Works', theme: 'grove', busHealthy: true },
|
||||
@@ -61,7 +62,9 @@ function fixture({ pollMs, fail = {}, down = [] } = {}) {
|
||||
return {
|
||||
busHealthy: true,
|
||||
reads: { business: async () => ({ business: { id, human: 'jason', projects: DATA[id].projects.map(p => p.repo) } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
|
||||
bus: { messages: async () => ({ messages: [], unread: 0 }), agents: async () => [], 'thread.claims': async () => [], inbox: async () => DATA[id].inbox.map(d => ({ ...d, business: id, raised_by_run: 'run', routed_to: 'human' })), tasks: async () => [], instances: async () => ({ business: id, instances: {} }) },
|
||||
bus: { messages: async () => ({ messages: messages[id] ?? [], unread: 0 }), agents: async () => [], 'thread.claims': async () => [], inbox: async () => DATA[id].inbox.map(d => ({ ...d, business: id, raised_by_run: 'run', routed_to: 'human' })), tasks: async () => [], instances: async () => ({ business: id, instances: {} }),
|
||||
// SP11: a decision's own page reads its trail.
|
||||
trail: async subject => DATA[id].inbox.filter(d => d.id === subject).map((d, i) => ({ table: 'decisions', seq: i + 1, ...d, business: id, raised_by_run: 'run', route_to: 'human', action: 'price.set', recommendation: null })) },
|
||||
chat: { projects: async () => DATA[id].projects, threads: async () => [] },
|
||||
};
|
||||
},
|
||||
@@ -69,8 +72,8 @@ function fixture({ pollMs, fail = {}, down = [] } = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
async function setup(t, { viewport = [1440, 900], hash = '/', workspaceTimeoutMs, fail, down } = {}) {
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ fail, down }), log: () => {}, ...(workspaceTimeoutMs ? { workspaceTimeoutMs } : {}) });
|
||||
async function setup(t, { viewport = [1440, 900], hash = '/', workspaceTimeoutMs, fail, down, messages } = {}) {
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ fail, down, messages }), log: () => {}, ...(workspaceTimeoutMs ? { workspaceTimeoutMs } : {}) });
|
||||
t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
await b.viewport(...viewport);
|
||||
@@ -224,6 +227,36 @@ test('Inbox lists both organizations, copies a command with --org and the full i
|
||||
await wait(`document.querySelectorAll("[data-ws-item]").length === 1 && document.querySelector("[data-ws-item]").dataset.wsItem === ${JSON.stringify(`beta:${BETA_DECISION}`)}`);
|
||||
});
|
||||
|
||||
// SP11 (#1873): an organization's own decision page names the class in the words the Inbox uses,
|
||||
// from the same map, not the raw class.
|
||||
test('SP11: the per-organization decision page names the class as the Inbox does', async t => {
|
||||
const { b, base, wait } = await setup(t, { hash: '#/inbox' });
|
||||
const item = `[data-ws-item="beta:${BETA_DECISION}"]`;
|
||||
await wait(`!!document.querySelector(${JSON.stringify(`${item} .ws-chip.within`)})`);
|
||||
const word = await b.evaluate(`document.querySelector(${JSON.stringify(`${item} .ws-chip.within`)}).textContent`);
|
||||
assert.equal(word, 'Within role');
|
||||
await b.navigate(`${base}/#/orgs/beta/inbox/${BETA_DECISION}`);
|
||||
await wait('!!document.querySelector("#bus-view .tag.tag-within-role")');
|
||||
const chip = await b.evaluate('document.querySelector("#bus-view .tag.tag-within-role").textContent');
|
||||
assert.match(chip, new RegExp(`^${word} · `), chip);
|
||||
assert.doesNotMatch(await b.evaluate('document.getElementById("bus-view").innerText'), /within-role/);
|
||||
});
|
||||
|
||||
test('SP11: the per-organization Messages page names a known class as the Inbox does and shows an unknown one as it is', async t => {
|
||||
const at = '2026-11-15T10:00:00.000Z';
|
||||
const messages = { beta: [
|
||||
{ id: 'msg-beta-1a2b3c4d-0001', from_role: 'pm', class: 'within-role', body: 'Price list is ready.', at, read: false },
|
||||
{ id: 'msg-beta-1a2b3c4d-0002', from_role: 'pm', class: 'NOTE', body: 'Logo is in.', at, read: true },
|
||||
] };
|
||||
const { b, base, wait } = await setup(t, { hash: '#/inbox', messages });
|
||||
await b.navigate(`${base}/#/orgs/beta/messages`);
|
||||
await wait('document.querySelectorAll("#message-list li").length === 2');
|
||||
const lines = await b.evaluate('JSON.stringify([...document.querySelectorAll("#message-list li p.small")].map(p => p.textContent))');
|
||||
const [known, unknown] = JSON.parse(lines);
|
||||
assert.match(known, /^pm · Within role · /, known);
|
||||
assert.match(unknown, /^pm · NOTE · /, unknown);
|
||||
});
|
||||
|
||||
test('A poll repaints in place: focus, caret and scroll stay where they were', async t => {
|
||||
const { b, wait } = await setup(t, { hash: '/projects', viewport: [1000, 420] });
|
||||
await wait('document.querySelector("[data-ws-table]") && document.querySelectorAll("[data-ws-project]").length === 3');
|
||||
|
||||
Reference in new issue
Block a user