Files
stack/packages/webui/src/public/assistant.js
T
jason.woltje 69ac78b97e fix(webui): sp7 shots round, a pane header keeps its close button on one line, and a thread that was not opened is said as a warning (#1873, lane sp7-polish)
- assistant.css: the pane title shrinks before the header tools wrap, so the close button stays on the title's line at 1440.
- assistant.js: paintNote draws the line under the lead; a refusal marks it data-refused and the line shows in the warning colour until the next change clears it.
- assistant-threads browser test: the refusal line carries data-refused, and closing a pane clears it.
2026-11-15 06:03:06 -06:00

512 lines
29 KiB
JavaScript

// #1873 SP6: the Assistant page (#/assistant), one system page with no organization prefix. Up to
// three chat threads, from any organization on the list, open next to each other. Each pane is the
// chat thread view (chat.js) in its own instance pinned to the pane's organization, so its reads and
// its sends name that organization in the address whichever organization the page opened last.
// A phone shows one pane at a time with a tab strip.
//
// The open set, its order and the phone's shown pane live in the address
// (#/assistant?open=<org>:<thread>,…&show=<org>:<thread>), checked before use and rewritten with
// replaceState. A bare #/assistant opens the set this browser had last.
//
// SP7: a plain click on a thread link anywhere in the Console (a project's conversation rows, "Open
// conversation") opens that thread as a pane here, added to the open set; #/assistant?add=<org>:<thread>
// carries it across the page change. A per-organization chat address typed or pasted still opens
// that organization's own chat view.
(() => {
'use strict';
if (!document.documentElement.hasAttribute('data-organizations')) return;
const data = window.MosaicWorkspaceData, ws = () => window.MosaicWorkspace;
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const enc = encodeURIComponent;
const MAX = 3, POLL = 10000, LIST_TTL = 3000, INFO_TTL = 30000, STORE = 'mosaic-assistant-open-v1';
const ORG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const paused = () => document.getElementById('pause')?.getAttribute('aria-pressed') === 'true';
const load = () => { try { return localStorage.getItem(STORE); } catch { return null; } };
const save = value => { try { localStorage.setItem(STORE, value); } catch {} };
const svg = d => `<svg class="i sm" viewBox="0 0 24 24" aria-hidden="true" focusable="false">${d}</svg>`;
const CLOSE = svg('<path d="M6 6l12 12M18 6 6 18"/>');
const setHtml = (el, html) => { if (el && el.innerHTML !== html) el.innerHTML = html; };
const setText = (el, text) => { if (el && el.textContent !== text) el.textContent = text; };
const rowsOf = body => (Array.isArray(body?.rows) ? body.rows : null);
// "<org>:<thread>,…" from the address: valid selectors only, each once, at most three.
function parse(query) {
const q = new URLSearchParams(query || ''), keys = [];
for (const part of (q.get('open') || '').split(',')) {
const cut = part.indexOf(':');
if (cut < 1) continue;
const org = part.slice(0, cut), id = part.slice(cut + 1).toLowerCase();
if (!ORG.test(org) || !UUID.test(id)) continue;
const key = `${org}:${id}`;
if (!keys.includes(key) && keys.length < MAX) keys.push(key);
}
const want = q.get('show') || '', at = want.indexOf(':');
const show = at > 0 ? `${want.slice(0, at)}:${want.slice(at + 1).toLowerCase()}` : null;
return { keys, show: keys.includes(show) ? show : null, named: q.has('open') };
}
const split = key => { const cut = key.indexOf(':'); return { org: key.slice(0, cut), id: key.slice(cut + 1) }; };
// ---------- reads ----------
// The organization list, and per organization its threads, projects and whether this Console may
// send there (its config's chatSend). An organization whose bus is down is not read.
let listAt = 0, listPromise = null;
const infos = new Map();
function orgList(fresh = false) {
if (!listPromise || fresh || Date.now() - listAt > LIST_TTL) {
listAt = Date.now();
listPromise = Promise.all([data.organizations(), ws()?.themes?.()]).then(([rows]) => (Array.isArray(rows) ? rows : null)).catch(() => null);
}
return listPromise;
}
const readConfig = org => fetch(`/api/orgs/${enc(org)}/config`, { cache: 'no-store', redirect: 'manual' })
.then(r => (r.ok ? r.json() : null)).then(c => c?.chatSend === true).catch(() => false);
function info(org, fresh = false) {
if (!org || org.invalid || org.busHealthy === false) return Promise.resolve({ threads: null, projects: null, chatSend: false, read: false });
const hit = infos.get(org.id);
if (hit && !fresh && Date.now() - hit.at < INFO_TTL) return hit.promise;
const promise = Promise.all([
data.threads(org.id).then(rowsOf).catch(() => null),
data.projects(org.id).then(rowsOf).catch(() => null),
readConfig(org.id),
]).then(([threads, projects, chatSend]) => ({
threads: threads && threads.filter(t => t && !t.shared && typeof t.id === 'string' && UUID.test(t.id)),
projects: projects && projects.filter(p => p && typeof p.id === 'string'),
chatSend, read: true,
}));
infos.set(org.id, { at: Date.now(), promise });
return promise;
}
// The rows ws().orgsOf reads: an organization not ok is named at the top of the page.
function orgRows(list, got) {
return list.map(o => {
const why = o.invalid ? ['invalid', o.reason || ''] : o.busHealthy === false ? ['down', ws().state(o)[2]] : got.get(o.id)?.threads === null ? ['timeout', 'the thread list did not answer'] : ['ok', ''];
return { org: o.id, label: o.label, theme: o.theme, state: why[0], reason: why[1] };
});
}
// ---------- state ----------
let mounted = null; // { root, panes: [pane], show, turn, timer, list, got, o, form }
let mountGen = 0, paneSeq = 0;
function writeUrl() {
if (!mounted) return;
const keys = mounted.panes.map(p => p.key);
const qs = keys.length ? `open=${keys.join(',')}${mounted.show && mounted.show !== keys[0] ? `&show=${mounted.show}` : ''}` : '';
save(keys.join(','));
const next = qs ? `#/assistant?${qs}` : '#/assistant';
if (location.hash !== next) history.replaceState(null, '', next);
}
// ---------- markup ----------
function frame() {
return `<section class="assist" data-assistant aria-labelledby="assist-title">
<div class="assist-bar">
<div class="assist-vh"><h1 id="assist-title" tabindex="-1">Assistant</h1>
<p class="sub">Threads from any organization, side by side. Each pane reads and sends through its own organization.</p></div>
<div class="assist-tools">
<label class="ws-sr" for="assist-open">Open thread</label>
<select id="assist-open" class="assist-open" data-assist-open><option value="">Open thread…</option></select>
</div>
</div>
<div data-assist-unread></div>
<p class="note" data-assist-note role="status"></p>
<form class="assist-new" data-assist-new hidden aria-labelledby="assist-new-title">
<h2 id="assist-new-title" tabindex="-1">New thread in <span data-assist-new-org></span></h2>
<div class="assist-new-fields">
<label><span>Project</span><select name="project" required></select></label>
<label><span>Agent</span><select name="instance" required></select></label>
<label><span>Name</span><input name="name" required maxlength="200" autocomplete="off"></label>
</div>
<div class="assist-new-actions"><button class="ws-btn primary" type="submit">Start thread</button><button class="ws-btn" type="button" data-assist-new-cancel>Cancel</button></div>
<p class="note" data-assist-new-status role="status"></p>
</form>
<div class="ws-seg assist-tabs" role="group" aria-label="Shown thread" data-assist-tabs></div>
<div class="assist-panes" data-assist-panes></div>
<p class="assist-empty" data-assist-empty hidden>No thread is open. Choose one under Open thread.</p>
</section>`;
}
function paneShell(p) {
const n = p.n;
return `<section class="assist-pane ws-o" data-assist-pane="${esc(p.key)}" id="assist-p${n}" aria-labelledby="assist-p${n}-title">
<div class="assist-pane-h">
<h2 id="assist-p${n}-title" data-assist-title>Reading…</h2>
<span class="assist-chips" data-assist-chips></span>
<a class="pane-tool assist-msgs" data-assist-pane-messages href="#/orgs/${enc(p.org)}/messages" aria-label="Messages in ${esc(p.org)}">Messages</a>
<button type="button" class="pane-tool" data-assist-close aria-label="Close thread">${CLOSE}</button>
</div>
<p class="assist-line" data-assist-line role="status" hidden></p>
<div class="assist-body" data-assist-body></div>
</section>`;
}
const els = p => ({
title: p.el.querySelector('[data-assist-title]'), chips: p.el.querySelector('[data-assist-chips]'),
close: p.el.querySelector('[data-assist-close]'), messages: p.el.querySelector('[data-assist-pane-messages]'), line: p.el.querySelector('[data-assist-line]'), body: p.el.querySelector('[data-assist-body]'),
});
// The picker: every organization on the list, its threads, and "New thread in …" where this
// Console may send. A stopped, invalid or silent organization is named and offers nothing.
function pickerHtml(list, got) {
const open = new Set(mounted.panes.map(p => p.key));
const groups = (list || []).filter(o => o && typeof o.id === 'string' && ORG.test(o.id)).map(o => {
const name = ws().label(o), i = got.get(o.id);
if (o.invalid) return `<optgroup label="${esc(name)} · invalid"><option disabled value="">Organization invalid</option></optgroup>`;
if (o.busHealthy === false) return `<optgroup label="${esc(name)} · ${esc(ws().state(o)[2])}"><option disabled value="">No threads while its bus is down</option></optgroup>`;
if (!i?.threads) return `<optgroup label="${esc(name)} · not answering"><option disabled value="">Threads unavailable</option></optgroup>`;
const project = id => i.projects?.find(p => p.id === id)?.name;
const rows = i.threads.map(t => {
const key = `${o.id}:${t.id.toLowerCase()}`, where = project(t.project);
return `<option value="${esc(key)}"${open.has(key) ? ' disabled' : ''}>${esc(t.name || t.id)}${where ? ` · ${esc(where)}` : ''}${open.has(key) ? ' (open)' : ''}</option>`;
}).join('');
const fresh = i.chatSend ? `<option value="new:${esc(o.id)}">New thread in ${esc(name)}…</option>` : '';
return `<optgroup label="${esc(name)}">${rows || (fresh ? '' : '<option disabled value="">No threads</option>')}${fresh}</optgroup>`;
});
return `<option value="">Open thread…</option>${groups.join('')}`;
}
// ---------- drawing ----------
async function draw({ fresh = false, navigated = false } = {}) {
if (!mounted) return;
const gen = mountGen, turn = ++mounted.turn;
const live = () => mounted && gen === mountGen && turn === mounted.turn;
clearTimeout(mounted.timer);
const list = await orgList(fresh);
if (!live()) return;
const openOrgs = new Set(mounted.panes.map(p => p.org));
const valid = (list || []).filter(o => o && typeof o.id === 'string' && ORG.test(o.id));
const got = new Map(await Promise.all(valid.map(async o => [o.id, await info(o, fresh || (!navigated && openOrgs.has(o.id)))])));
if (!live()) return;
mounted.list = list; mounted.got = got;
mounted.o = ws().orgsOf({ orgs: orgRows(valid, got) });
paintTop();
for (const p of mounted.panes) drawPane(p);
paintTabs(); layout();
mounted.timer = setTimeout(tick, POLL);
}
function tick() {
if (!mounted) return;
if (paused()) { mounted.timer = setTimeout(tick, POLL); return; }
void draw();
}
// The line under the lead: how many panes are open, or why a thread was not opened (drawn as a warning).
function paintNote() {
const el = mounted.root.querySelector('[data-assist-note]');
setText(el, mounted.notice || (mounted.panes.length ? `${mounted.panes.length} open, up to ${MAX}` : ''));
el?.toggleAttribute('data-refused', !!mounted.notice);
}
function paintTop() {
const { root, list, o, panes } = mounted;
setHtml(root.querySelector('[data-assist-unread]'), list ? ws().unreadNote(o, [], 'assistant') : '<p class="note ws-unread-note">The organization list could not be read. Open panes only read.</p>');
paintNote();
const pick = root.querySelector('[data-assist-open]');
if (document.activeElement !== pick) { setHtml(pick, pickerHtml(list, mounted.got)); pick.value = ''; }
const full = panes.length >= MAX;
pick.disabled = full;
pick.title = full ? `${MAX} threads are open. Close one to open another.` : '';
root.querySelector('[data-assist-empty]').hidden = panes.length > 0;
}
function drawPane(p) {
if (!mounted || !p.el.isConnected) return;
const { list, got, o } = mounted, e = els(p);
const org = list?.find(x => x?.id === p.org) || null, i = got.get(p.org);
const name = org ? ws().label(org) : p.org;
const thread = i?.threads?.find(t => t.id.toLowerCase() === p.id) || null;
const title = thread?.name || `Thread ${p.id.slice(0, 8)}`;
p.title = title;
setText(e.title, title);
e.close.setAttribute('aria-label', `Close ${title}, ${name}`);
e.messages.setAttribute('aria-label', `Messages in ${name}`);
if (org) p.el.setAttribute('style', ws().colours(org)); else p.el.removeAttribute('style');
const project = thread && i.projects?.find(x => x.id === thread.project)?.name;
setHtml(e.chips, `${ws().orgChip(o, p.org)}${ws().projChip(project || '')}`);
let line = '', readable = true, why = 'Nothing is read for this thread.', back = false;
if (!list) line = 'The organization list could not be read, so sending is off here until it reads again.';
else if (!org) { line = `${p.org} is not on your organization list.`; readable = false; }
else if (org.invalid) { line = `${name}: organization invalid. Fix its file under Settings, Organizations.`; readable = false; }
else if (org.busHealthy === false) {
const how = org.busStopped ? 'stopped' : 'unavailable';
line = `${name}: bus is ${how}. This pane cannot read or send until it starts again; the other panes are not affected.`;
why = `Nothing is read for this thread while ${name}'s bus is ${how}.`; readable = false; back = true;
}
else if (i?.threads && !thread) line = `This thread is not in ${name}'s thread list.`;
// A held message the last render had no composer for stays in the line between polls, so the
// status line neither flickers nor is announced again on every read.
const withHeld = () => {
const cps = [...p.draft.replace(/\s+/g, ' ').trim()];
return `${line ? `${line} ` : ''}Your unsent message is kept until this thread can send: “${cps.length > 200 ? `${cps.slice(0, 200).join('')}…` : cps.join('')}”`;
};
const shown = readable && p.held && p.draft ? withHeld() : line;
setText(e.line, shown); e.line.hidden = !shown;
if (!readable) {
p.held = false;
// An unsent message outlives the chat view: it is held on the pane and put back when the
// thread reads again.
const typed = p.chat && e.body.querySelector('form textarea')?.value;
if (typed?.trim()) p.draft = typed;
p.chat?.dispose(); p.chat = null;
const kept = !p.draft ? '' : back ? ' Your unsent message is kept and comes back here when it reads again.' : ' Your unsent message is kept while this pane stays open.';
setHtml(e.body, `<p class="ws-line">${esc(why)}${kept}</p>`);
return;
}
if (!p.chat) { p.chat = window.MosaicChat.pane(p.org); p.first = true; }
const first = p.first, chat = p.chat, seq = ++p.seq; p.first = false;
void chat.render({ path: `/chat/threads/${p.id}`, container: e.body, canSend: !!list && i?.chatSend === true, navigated: first }).then(() => {
// Only the latest render of this pane decides: an earlier one that a newer render cut short
// has no composer yet and must not claim the thread cannot send.
if (seq !== p.seq || p.chat !== chat || !p.el.isConnected) return;
const field = p.draft ? e.body.querySelector('form textarea') : null;
if (p.draft && !field) {
// The thread reads but this render drew no composer (it cannot send now): the held message is
// quoted in the pane's status line until a composer is back to take it.
p.held = true; const text = withHeld(); setText(e.line, text); e.line.hidden = false;
return;
}
// Put the held message back only into an empty composer; text typed meanwhile is never overwritten.
if (field && !field.value) { field.value = p.draft; field.dispatchEvent(new Event('input', { bubbles: true })); }
if (field && field.value === p.draft) p.draft = '';
if (p.held && (field || !p.draft)) { p.held = false; setText(e.line, line); e.line.hidden = !line; }
}, () => {});
}
function paintTabs() {
const { root, panes, show } = mounted;
const tabs = root.querySelector('[data-assist-tabs]');
const focused = tabs.contains(document.activeElement) ? document.activeElement.dataset.assistShow : null;
const html = panes.map(p => `<button type="button" data-assist-show="${esc(p.key)}" aria-pressed="${show === p.key}" aria-controls="assist-p${p.n}">${esc(p.title || 'Reading…')}</button>`).join('');
if (tabs.innerHTML !== html) { tabs.innerHTML = html; if (focused) tabs.querySelector(`[data-assist-show="${CSS.escape(focused)}"]`)?.focus({ preventScroll: true }); }
tabs.hidden = panes.length < 2;
}
function layout() {
const { root, panes, show } = mounted;
const box = root.querySelector('[data-assist-panes]');
box.style.setProperty('--assist-n', String(Math.max(1, panes.length)));
box.hidden = !panes.length;
for (const p of panes) p.el.classList.toggle('alt', show !== p.key);
fit();
}
// Wide screens: the panes fill the window below the bar and each scrolls on its own.
const wide = window.matchMedia('(min-width: 761px)');
function fit() {
if (!mounted) return;
const box = mounted.root.querySelector('[data-assist-panes]');
if (!wide.matches) { box.style.height = ''; return; }
const foot = document.querySelector('body > .foot, .foot')?.getBoundingClientRect().height || 0;
const top = box.getBoundingClientRect().top;
box.style.height = `${Math.max(360, Math.floor(window.innerHeight - Math.max(top, 0) - foot - 16))}px`;
}
// ---------- panes ----------
function makePane(key) {
const { org, id } = split(key);
const p = { key, org, id, n: ++paneSeq, el: null, chat: null, first: true, title: null, draft: '', held: false, seq: 0 };
const box = mounted.root.querySelector('[data-assist-panes]');
box.insertAdjacentHTML('beforeend', paneShell(p));
p.el = box.lastElementChild;
return p;
}
function addPane(key, { focus = false } = {}) {
if (!mounted) return;
const had = mounted.panes.find(p => p.key === key);
if (had) { mounted.show = key; paintTabs(); layout(); writeUrl(); return; }
if (mounted.panes.length >= MAX) return;
const p = makePane(key);
mounted.panes.push(p); mounted.show = key;
writeUrl();
if (mounted.got) { paintTop(); drawPane(p); paintTabs(); layout(); }
if (focus) els(p).title.setAttribute('tabindex', '-1'), els(p).title.focus({ preventScroll: true });
}
// SP7: a thread opened from a link takes a free slot or, with all three taken, the place of the
// first pane that holds no unsent message. When every pane holds one, nothing is replaced.
const holds = p => !!p.draft.trim() || !!p.chat?.hasDraft();
function place(key) {
const had = mounted.panes.find(p => p.key === key);
if (had) return had;
if (mounted.panes.length < MAX) { const p = makePane(key); mounted.panes.push(p); return p; }
const old = mounted.panes.find(p => !holds(p));
if (!old) return null;
const p = makePane(key);
old.chat?.dispose(); old.chat = null; old.el.replaceWith(p.el);
mounted.panes[mounted.panes.indexOf(old)] = p;
return p;
}
const REFUSED = `That thread was not opened: all ${MAX} panes hold an unsent message. Send or clear one, or close a pane, then open the thread again.`;
function add(key) {
if (!mounted) return false;
const p = place(key);
if (!p) { mounted.notice = REFUSED; paintNote(); return false; }
mounted.notice = ''; mounted.show = key;
writeUrl();
if (mounted.got) { paintTop(); drawPane(p); paintTabs(); layout(); }
els(p).title.setAttribute('tabindex', '-1'); els(p).title.focus({ preventScroll: true });
return true;
}
const addOf = query => { const v = new URLSearchParams(query || '').get('add'); return v ? parse(new URLSearchParams({ open: v }).toString()).keys[0] || null : null; };
const THREAD_LINK = /^#\/(?:orgs\/([^/?#]+)\/)?(?:projects|chat)\/threads\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
document.addEventListener('click', e => {
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
const a = e.target.closest?.('a[href]');
if (!a || a.hasAttribute('download') || (a.target && a.target !== '_self')) return;
const m = THREAD_LINK.exec(a.getAttribute('href') || '');
const org = m && (m[1] || window.MosaicOrg?.id);
if (!m || !org || !ORG.test(org)) return;
e.preventDefault();
const key = `${org}:${m[2].toLowerCase()}`;
if (mounted && /^#\/assistant(?:\?|$)/.test(location.hash)) add(key);
else location.hash = `#/assistant?add=${key}`;
});
function closePane(p) {
if (!mounted || !p) return;
const at = mounted.panes.indexOf(p);
if (at < 0) return;
mounted.notice = '';
p.chat?.dispose(); p.chat = null; p.el.remove();
mounted.panes.splice(at, 1);
const next = mounted.panes[Math.min(at, mounted.panes.length - 1)];
if (mounted.show === p.key) mounted.show = next?.key ?? null;
writeUrl(); paintTop(); paintTabs(); layout();
// The close button just left the page: focus the next pane's close control, or the picker.
(next ? els(next).close : mounted.root.querySelector('[data-assist-open]')).focus({ preventScroll: true });
}
// ---------- a new thread ----------
async function openForm(orgId) {
if (!mounted) return;
const org = mounted.list?.find(o => o?.id === orgId);
if (!org || org.busHealthy === false || mounted.got.get(orgId)?.chatSend !== true) return;
const f = mounted.root.querySelector('[data-assist-new]'), formTurn = ++mounted.formTurn;
mounted.form = { org: orgId, busy: false };
f.hidden = false; f.reset();
setText(f.querySelector('[data-assist-new-org]'), ws().label(org));
setText(f.querySelector('[data-assist-new-status]'), 'Reading projects and agents…');
const project = f.elements.project, instance = f.elements.instance;
project.innerHTML = ''; instance.innerHTML = ''; project.disabled = true; instance.disabled = true;
f.querySelector('h2').focus({ preventScroll: true });
const [i, agents] = await Promise.all([
info(org, true),
fetch(`/api/orgs/${enc(orgId)}/chat/instances`, { cache: 'no-store', redirect: 'manual' }).then(r => (r.ok ? r.json() : null)).then(rowsOf).catch(() => null),
]);
if (!mounted || mounted.formTurn !== formTurn || f.hidden) return;
const projects = (i.projects || []).filter(p => !p.archived);
project.innerHTML = projects.map(p => `<option value="${esc(p.id)}">${esc(p.name || p.id)}</option>`).join('');
instance.innerHTML = (agents || []).filter(a => a && typeof a.instance === 'string').map(a => `<option value="${esc(a.instance)}">${esc(a.name || a.instance)}</option>`).join('');
project.disabled = !projects.length; instance.disabled = !agents?.length;
const missing = [i.projects === null ? 'Projects could not be read.' : !projects.length ? 'This organization has no open project.' : '', agents === null ? 'Agents could not be read.' : !agents.length ? 'No agent takes threads here.' : ''].filter(Boolean).join(' ');
setText(f.querySelector('[data-assist-new-status]'), missing);
}
function closeForm() {
if (!mounted) return;
mounted.form = null; mounted.formTurn++;
mounted.root.querySelector('[data-assist-new]').hidden = true;
}
async function submitForm(f) {
const form = mounted?.form;
if (!form || form.busy) return;
const status = f.querySelector('[data-assist-new-status]');
const body = { project: f.elements.project.value, name: f.elements.name.value.trim(), instance: f.elements.instance.value };
if (!body.project || !body.instance || !body.name) { setText(status, 'Choose a project and an agent, and name the thread.'); return; }
form.busy = true;
const submit = f.querySelector('[type=submit]'); submit.disabled = true;
setText(status, 'Starting the thread…');
let result = null, why = '';
try {
const r = await fetch(`/api/orgs/${enc(form.org)}/chat/threads`, { method: 'POST', cache: 'no-store', redirect: 'manual', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
const v = await r.json().catch(() => null);
if (r.ok && typeof v?.id === 'string' && UUID.test(v.id)) result = v.id.toLowerCase();
else why = r.type === 'opaqueredirect' || r.status === 401 ? 'You are signed out. Reload the page to sign in again.' : `The thread was not started${typeof v?.error === 'string' ? ` (${v.error})` : ''}.`;
} catch { why = 'Outcome unknown. Look under Open thread before trying again.'; }
if (mounted?.form !== form) return;
form.busy = false; submit.disabled = false;
if (!result) { setText(status, why); return; }
infos.delete(form.org);
const key = `${form.org}:${result}`;
if (mounted.panes.length >= MAX && !mounted.panes.some(p => p.key === key)) { setText(status, `Started. ${MAX} threads are open; close one to open it.`); return; }
closeForm();
addPane(key, { focus: true });
void draw({ fresh: false });
}
// ---------- events (bound once to the mount root) ----------
function bind(root) {
root.addEventListener('change', e => {
const pick = e.target.closest?.('[data-assist-open]');
if (!pick || !mounted) return;
const value = pick.value;
pick.value = '';
if (value.startsWith('new:')) { void openForm(value.slice(4)); return; }
const want = parse(`open=${value}`).keys[0];
if (want) { mounted.notice = ''; addPane(want); }
});
root.addEventListener('click', e => {
if (!mounted) return;
const show = e.target.closest?.('[data-assist-show]');
if (show) { const p = mounted.panes.find(x => x.key === show.dataset.assistShow); if (p) { mounted.show = p.key; paintTabs(); layout(); writeUrl(); } return; }
const close = e.target.closest?.('[data-assist-close]');
if (close) { closePane(mounted.panes.find(x => x.el.contains(close))); return; }
if (e.target.closest?.('[data-assist-new-cancel]')) { closeForm(); root.querySelector('[data-assist-open]').focus({ preventScroll: true }); }
});
root.addEventListener('submit', e => {
const f = e.target.closest?.('[data-assist-new]');
if (!f) return;
e.preventDefault();
void submitForm(f);
});
}
// ---------- public ----------
async function mount(container, query, { navigated = false } = {}) {
unmount();
++mountGen;
container.innerHTML = frame();
const root = container.querySelector('[data-assistant]');
mounted = { root, panes: [], show: null, turn: 0, timer: null, list: null, got: null, o: null, form: null, formTurn: 0, notice: '' };
bind(root);
window.addEventListener('resize', fit);
document.title = 'Assistant · Mosaic Console';
let want = parse(query);
if (!want.named) want = parse(`open=${load() || ''}`);
for (const key of want.keys) mounted.panes.push(makePane(key));
mounted.show = want.show || want.keys[0] || null;
// A thread link followed from another page: a fresh set holds no unsent message, so it always lands.
const extra = addOf(query), added = extra && place(extra);
if (added) mounted.show = extra;
if (added) { els(added).title.setAttribute('tabindex', '-1'); els(added).title.focus({ preventScroll: true }); }
else if (navigated) root.querySelector('h1').focus({ preventScroll: true });
writeUrl(); paintTabs(); layout();
root.querySelector('[data-assist-empty]').hidden = mounted.panes.length > 0;
await draw({ navigated: true });
}
function unmount() {
if (!mounted) return;
clearTimeout(mounted.timer);
for (const p of mounted.panes) p.chat?.dispose();
window.removeEventListener('resize', fit);
mounted = null; mountGen++;
}
// The Console's Refresh, and a redraw of the same page: every pane reads again now. A query that
// names a different open set (an address edited in place) opens that set.
function refresh(query) {
if (!mounted) return;
if (typeof query === 'string') {
const want = parse(query);
if (want.named && want.keys.join(',') !== mounted.panes.map(p => p.key).join(',')) {
for (const p of [...mounted.panes]) if (!want.keys.includes(p.key)) { p.chat?.dispose(); p.el.remove(); }
const kept = new Map(mounted.panes.map(p => [p.key, p]));
mounted.panes = want.keys.map(k => kept.get(k) || makePane(k));
const box = mounted.root.querySelector('[data-assist-panes]');
for (const p of mounted.panes) box.append(p.el);
mounted.show = want.show || want.keys[0] || null;
writeUrl();
}
const extra = addOf(query);
if (extra) add(extra);
}
infos.clear();
void draw({ fresh: true });
}
const hasDraft = () => !!mounted && mounted.panes.some(p => !!p.draft.trim() || !!p.chat?.hasDraft());
// A write that changed an organization reads it again.
document.addEventListener('mosaic-org-changed', e => { if (mounted && mounted.panes.some(p => p.org === e.detail?.org)) { infos.delete(e.detail.org); void draw(); } });
window.MosaicAssistant = Object.freeze({ mount, unmount, refresh, hasDraft, add });
})();