Row 59 round 2, owner Filbert, candidate manifest 9acfac19 on 3d298920.
Approved by Darkwing (27591) and Dewey (27595); Sage's gate green.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
696 lines
66 KiB
JavaScript
696 lines
66 KiB
JavaScript
// Slice 1 S5 (#1522): inbox, tasks, agents and trail, read through /api/bus (src/bus.mjs),
|
||
// which reaches only the four reads of the Q1 module (lead decision 56). Nothing on these
|
||
// pages writes: decisions are answered with `mosaic decide` in a terminal, and Copy only
|
||
// puts that command on the clipboard (Q4). What no Q1 read returns is labelled where it
|
||
// would show (lead decision 63). The control board (app.js) is untouched and stays at #/.
|
||
(() => {
|
||
'use strict';
|
||
const $ = id => document.getElementById(id);
|
||
const esc = v => String(v ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||
// The CHAT-02 rules: C0 controls show as control pictures, C1 and bidi controls as
|
||
// [U+XXXX]. Newlines and tabs stay. Markdown stays as its source.
|
||
const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f]/g, UNSEEN = /[\u0080-\u009f--]/g;
|
||
const inert = v => String(v ?? '').replace(CONTROL, c => c === '\u007f' ? '␡' : String.fromCharCode(0x2400 + c.charCodeAt(0))).replace(UNSEEN, c => `[U+${c.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0')}]`);
|
||
const txt = v => esc(inert(v)); // every agent-, tracker- or bus-authored string goes through this
|
||
const announce = text => { $('announce').textContent = text; };
|
||
const enc = encodeURIComponent;
|
||
const SAFE = /^[a-zA-Z0-9][a-zA-Z0-9_.:/-]{0,159}$/; // the broker's identifier rule
|
||
const TASK = /^vikunja:(\d+)\/(\d+)$/;
|
||
const CLOSE = ['resolved', 'withdrawn', 'expired'];
|
||
|
||
// ---------------------------------------------------------------- reads
|
||
// One GET per Q1 read. The last good answer for each read is kept, so a failed read
|
||
// can show what was read before, labelled as such.
|
||
const last = new Map();
|
||
class ReadError extends Error { constructor(code, message, status) { super(message); this.code = code; this.status = status; } }
|
||
async function fetchRead(verb, subject) {
|
||
let res, body;
|
||
try {
|
||
res = await fetch(`/api/bus/${verb}${subject === undefined ? '' : `?subject=${enc(subject)}`}`, { cache: 'no-store' });
|
||
body = await res.json();
|
||
} catch { throw new ReadError('unreachable', 'The Console server did not answer.', 0); }
|
||
if (!res.ok || !body || !Array.isArray(body.rows)) throw new ReadError(typeof body?.error === 'string' ? body.error : 'invalid-response', typeof body?.message === 'string' ? body.message : `HTTP ${res.status}`, res.status);
|
||
return { rows: body.rows, at: body.at };
|
||
}
|
||
// Row 54 (#1543): the queue, business and settings reads (src/reads.mjs), GET only. Their
|
||
// error carries the module's own message, which the server has already redacted.
|
||
async function fetchApi(path) {
|
||
let res, body;
|
||
try {
|
||
res = await fetch(`/api/${path}`, { cache: 'no-store' });
|
||
body = await res.json();
|
||
} catch { throw Object.assign(new ReadError('unreachable', 'The Console server did not answer.', 0), { module: true }); }
|
||
if (!res.ok || !body || typeof body !== 'object' || Array.isArray(body)) throw Object.assign(new ReadError(typeof body?.error === 'string' ? body.error : 'invalid-response', typeof body?.message === 'string' ? body.message : `HTTP ${res.status}`, res.status), { module: true });
|
||
return body;
|
||
}
|
||
// A reader for one render. `live` asks the server; otherwise only kept answers are used.
|
||
function reader(live) {
|
||
const used = [];
|
||
const get = async (verb, subject) => {
|
||
const k = subject === undefined ? verb : `${verb}:${subject}`;
|
||
let r;
|
||
if (live) { r = await fetchRead(verb, subject); last.set(k, r); }
|
||
else if (!(r = last.get(k))) throw new ReadError('not-read', 'not read before');
|
||
used.push(r.at);
|
||
return r.rows;
|
||
};
|
||
get.api = async path => {
|
||
const k = `api:${path}`;
|
||
let r;
|
||
if (live) { r = await fetchApi(path); last.set(k, r); }
|
||
else if (!(r = last.get(k))) throw new ReadError('not-read', 'not read before');
|
||
used.push(r.at);
|
||
return r;
|
||
};
|
||
get.oldest = () => used.filter(Boolean).sort()[0] || null;
|
||
return get;
|
||
}
|
||
const of = (rows, table) => rows.filter(r => r.table === table);
|
||
|
||
// One decision from its trail. An open human decision's inbox row carries the
|
||
// authorization context; otherwise it comes from the trail's decision.raise event,
|
||
// the rule the broker's own decision view uses.
|
||
async function decision(get, id, row = null) {
|
||
const tr = await get('trail', id), d = of(tr, 'decisions').find(x => x.id === id);
|
||
if (!d) return null;
|
||
const ev = of(tr, 'decision_events').filter(x => x.decision === id), closed = ev.filter(x => CLOSE.includes(x.op)).at(-1) || null;
|
||
const raise = of(tr, 'events').find(e => e.kind === 'action.allowed' && e.body?.operation === 'decision.raise' && e.body?.decision === id);
|
||
const mids = new Set(of(tr, 'messages').filter(m => m.decision === id).map(m => m.id));
|
||
const dm = of(tr, 'deliveries').find(x => mids.has(x.message) && x.transport === 'discord-dm' && x.op === 'delivered');
|
||
return {
|
||
id, at: d.at, class: d.class, action: d.action, routeTo: d.route_to, question: d.question,
|
||
options: Array.isArray(d.options) ? d.options : [], recommendation: d.recommendation, taskRef: d.task_ref, requirementRef: d.requirement_ref,
|
||
blocking: !!d.blocking, raisedBy: { role: d.raised_by_role, run: d.raised_by_run },
|
||
authorization: row ? row.authorization : raise ? { action: d.action, target: raise.body.target, approvalChoice: raise.body.approvalChoice } : null,
|
||
seen: ev.filter(x => x.op === 'seen'), closed, dm: dm ? { at: dm.at } : null, supersedes: d.supersedes, trail: tr,
|
||
};
|
||
}
|
||
// Earlier versions through `supersedes`, newest first, one trail read each, at most ten.
|
||
async function chain(get, d) {
|
||
const out = [];
|
||
for (let x = d, n = 0; x?.supersedes && n < 10; n++) { x = await decision(get, x.supersedes); if (x) out.push(x); }
|
||
return out;
|
||
}
|
||
// A task from its task_current row; the trail adds what only events carry.
|
||
function task(row, tr = null) {
|
||
const ref = row.task_ref, f = row.fields || {}, [, project, id] = ref.match(TASK) || [, null, null];
|
||
const evs = tr ? of(tr, 'events').filter(e => e.subject === ref) : [], snaps = tr ? of(tr, 'task_snapshots') : [];
|
||
const shape = f.gone ? snaps.filter(s => !s.fields?.gone).at(-1)?.fields || {} : f;
|
||
const created = evs.find(e => e.kind === 'task.created');
|
||
const ext = evs.filter(e => e.kind === 'task.changed.external').at(-1);
|
||
// task_current already skips stale reads, so a current row from a poll is a change the
|
||
// stack didn't make: to a task the stack wrote ('changed') or to one it never wrote ('foreign').
|
||
const external = !f.gone && row.source === 'poll' ? { kind: tr ? (snaps.some(s => s.source === 'self') ? 'changed' : 'foreign') : null, via: row.via, readAt: row.read_at, changed: Array.isArray(ext?.body?.changed) ? ext.body.changed : [], comments: ext?.body?.comments || 0 } : null;
|
||
const missingEv = evs.filter(e => e.kind === 'task.missing').at(-1);
|
||
return {
|
||
ref, project: project && +project, id: id && +id, title: shape.title ?? null, description: shape.description ?? '',
|
||
bucket: f.gone ? null : f.bucket, done: !!f.done, assignees: Array.isArray(shape.assignees) ? shape.assignees : [],
|
||
// The bus 'tasks' row carries both (row 58). A tombstone's row has no priority, so the detail
|
||
// view falls back to the last snapshot that read the task.
|
||
requirement: row.requirement ?? created?.body?.requirement ?? null, request: created?.body?.request ?? null,
|
||
due: shape.due_date ?? null, priority: row.priority ?? shape.priority ?? null, percent: shape.percent_done ?? 0,
|
||
changedAt: row.at, seq: row.seq, external,
|
||
conflicts: evs.filter(e => e.kind === 'task.conflict').map(e => ({ at: e.at, ...e.body })),
|
||
missing: f.gone ? { reason: f.gone, at: row.at, project: missingEv?.body?.project ?? null } : null,
|
||
decisions: tr ? of(tr, 'decisions').filter(d => d.task_ref === ref) : [],
|
||
// Rows after the current one are reads task_current skipped (lead decision 59).
|
||
snapshots: snaps.map(s => ({ at: s.at, source: s.source, via: s.via, readAt: s.read_at, role: s.role, stale: s.seq > row.seq, fields: s.fields || {} })),
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------- trail lines
|
||
const group = k => k === 'human.input' ? 'request' : k.startsWith('session.') || k.startsWith('launch.') || k.startsWith('claim.') ? 'launch' : k.startsWith('task.') || k.startsWith('snapshot.') ? 'task' : k.startsWith('review.') ? 'review' : k.startsWith('action.') || k.startsWith('decision.') ? 'decision' : 'other';
|
||
const bucketText = f => f?.gone ? `gone (${f.gone})` : `bucket ${f?.bucket}`;
|
||
const short = id => String(id ?? '').slice(0, 8);
|
||
const plural = (n, one) => `${n} ${one}${n === 1 ? '' : 's'}`;
|
||
function line(r, ctx) {
|
||
const out = { at: r.at, actor: null, by: 'broker', decision: null, note: null };
|
||
if (r.table === 'events') {
|
||
const b = r.body && typeof r.body === 'object' ? r.body : {};
|
||
out.kind = r.kind; out.decision = typeof b.decision === 'string' ? b.decision : null;
|
||
if (r.actor_role) out.actor = { role: r.actor_role, run: r.actor_run }; else if (r.kind === 'human.input' || r.kind.startsWith('launch.')) out.by = 'human';
|
||
const map = {
|
||
'session.launched': () => `Session started: ${r.actor_role} as ${r.actor_run} (${b.harness}${b.address ? `, ${b.address}` : ''})`,
|
||
'session.ended': () => `Session ended: ${r.actor_role} ${r.actor_run}${b.reason ? ` (${b.reason}${b.exit !== undefined ? `, exit ${b.exit}` : ''})` : ''}`,
|
||
'human.input': () => b.kind === 'answer' ? (b.op === 'resolved' ? `The human chose “${b.choice}” from the CLI` : `The human ${b.op === 'seen' ? 'marked it seen' : b.op} from the CLI`)
|
||
: b.kind === 'instruction' ? 'Request from the human, recorded from the CLI' : b.kind === 'admin' ? 'Admin command from the human, CLI' : 'Input from the human',
|
||
'task.created': () => `Created for ${b.requirement}`,
|
||
'task.assigned': () => `Assigned to ${b.role}`,
|
||
'task.state': () => `Moved ${b.previous ? `from ${b.previous} ` : ''}to ${b.state}${b.percent_done !== undefined ? `, ${b.percent_done}% done` : ''}${b.comment ? ', with a comment in Vikunja' : ''}`,
|
||
'task.closed': () => `Closed with verdict ${b.verdict}`,
|
||
'task.changed.external': () => {
|
||
const changed = Array.isArray(b.changed) ? b.changed : [], comments = b.comments ? plural(b.comments, 'new comment') : '';
|
||
if (b.previous === null) return 'Read from Vikunja for the first time: the stack did not create it';
|
||
return changed.length ? `Changed in Vikunja: ${changed.join(', ')}${comments ? `, and ${comments}` : ''}` : comments ? `${comments[0].toUpperCase()}${comments.slice(1)} in Vikunja` : 'Read from Vikunja';
|
||
},
|
||
'task.conflict': () => `Write refused (${b.verb}): Vikunja changed after the stack read it (expected ${short(b.expected)}, found ${short(b.actual)})`,
|
||
'task.missing': () => `No longer readable in Vikunja (${b.reason}${b.project ? `, now in project ${b.project}` : ''})`,
|
||
'review.requested': () => 'Review requested',
|
||
'review.verdict': () => `Review verdict: ${b.verdict ?? 'not recorded'}`,
|
||
'action.refused': () => `Refused: ${b.code}`,
|
||
'action.allowed': () => b.operation === 'decision.raise' ? `Raised with this context: choosing “${b.approvalChoice}” approves ${b.action}${b.target ? ` on ${b.target}` : ''}` : `Allowed ${b.action}${b.target ? ` on ${b.target}` : ''}`,
|
||
'launch.revoked': () => 'Launching revoked from the CLI',
|
||
'launch.restored': () => 'Launching restored from the CLI',
|
||
'credential.expiring': () => `${b.service} credential for ${b.instance} expires ${b.expires ?? 'soon'}`,
|
||
'credential.expired': () => `${b.service} credential for ${b.instance} expired`,
|
||
'credential.changed': () => `${b.service} credential for ${b.instance} changed`,
|
||
};
|
||
out.text = (map[r.kind] || (() => r.kind))();
|
||
if (r.kind === 'human.input' && ctx.asked.has(r.id)) out.note = 'the request task.created names (lead decision 56, Q3)';
|
||
if (r.kind === 'human.input' && b.kind === 'answer') out.groupAs = 'decision'; // an answer, not a request
|
||
} else if (r.table === 'messages') {
|
||
out.kind = `message.${String(r.class).toLowerCase()}`; out.decision = r.decision;
|
||
if (r.from_role === 'human') out.by = 'human'; else out.actor = { role: r.from_role, run: r.from_run };
|
||
out.text = `${r.from_role === 'human' ? 'The human' : r.from_role} to ${r.to_role}: ${r.body}`;
|
||
out.groupAs = r.from_role === 'human' ? 'request' : r.decision ? 'decision' : 'other';
|
||
} else if (r.table === 'deliveries') {
|
||
const m = ctx.messages[r.message];
|
||
out.kind = `delivery.${r.op}`; out.decision = m?.decision || null;
|
||
out.text = `Message ${r.op}${r.holder_run ? ` to ${r.holder_run}` : ''}${r.transport ? ` by ${r.transport}` : ''}${r.transport === 'discord-dm' ? ' (DM to the human)' : ''}`;
|
||
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]}`;
|
||
} else if (r.table === 'decision_events') {
|
||
// The human answers from the CLI (via cli); every other row is the broker's.
|
||
out.kind = `decision.${r.op}`; out.decision = r.decision; out.by = r.via === 'cli' ? 'human' : 'broker';
|
||
out.text = r.op === 'resolved' ? `${r.by} chose “${r.choice}” via ${r.via}` : r.op === 'seen' ? `${r.by} saw it${r.via ? ` via ${r.via}` : ''}` : `${r.op[0].toUpperCase()}${r.op.slice(1)} by ${r.by}${r.note ? `: ${r.note}` : ''}`;
|
||
} else if (r.table === 'task_snapshots') {
|
||
out.kind = `snapshot.${r.source}`;
|
||
if (r.source === 'self') out.actor = { role: r.role, run: r.run };
|
||
out.text = r.source === 'self' ? `Written: ${bucketText(r.fields)}` : `Read from Vikunja (${r.via} read sent ${String(r.read_at).slice(11, 19)}): ${bucketText(r.fields)}`;
|
||
if (ctx.current != null && r.seq > ctx.current) out.note = 'stale read, not current (task_current skips it)';
|
||
} else if (r.table === 'role_claims') {
|
||
out.kind = `claim.${r.op}`; out.actor = { role: r.role, run: r.holder_run };
|
||
out.text = r.op === 'claim' ? `${r.role} claimed by ${r.holder_run} (${r.harness}${r.address ? `, ${r.address}` : ''})` : `${r.role} ${r.op === 'release' ? 'released' : 'revoked'} by ${r.by}${r.reason ? `: ${r.reason}` : ''}`;
|
||
} else { out.kind = String(r.table); out.text = 'A row this page does not know how to show'; }
|
||
out.group = out.groupAs || group(out.kind);
|
||
return out;
|
||
}
|
||
function lines(tr, current = null) {
|
||
const ctx = { current, asked: new Set(of(tr, 'events').filter(e => e.kind === 'task.created').map(e => e.body?.request)), messages: Object.fromEntries(of(tr, 'messages').map(m => [m.id, m])) };
|
||
return tr.map(r => line(r, ctx));
|
||
}
|
||
|
||
// ---------------------------------------------------------------- render helpers
|
||
function ago(iso) {
|
||
const t = Date.parse(iso);
|
||
if (!Number.isFinite(t)) return String(iso ?? 'unknown');
|
||
const s = Math.max(0, Math.round((Date.now() - t) / 1000)), m = Math.floor(s / 60), h = Math.floor(m / 60);
|
||
return s < 60 ? `${s} s ago` : m < 60 ? `${m} min ago` : h < 24 ? `${h} h ${m % 60} min ago` : `${Math.floor(h / 24)} d ago`;
|
||
}
|
||
const when = iso => iso ? `<time datetime="${esc(iso)}" title="${esc(iso)}">${esc(ago(iso))}</time>` : 'never';
|
||
const clock = iso => iso ? `<time datetime="${esc(iso)}" title="${esc(iso)}">${esc(String(iso).slice(11, 16))}</time>` : '';
|
||
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>`;
|
||
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>`;
|
||
const taskLink = ref => ref ? `<a href="#/tasks/${enc(ref)}">#${esc(String(ref).split('/')[1])}</a>` : '';
|
||
// Rows without an actor role come from the human (CLI, outside any run) or from the broker itself.
|
||
const actor = (a, by) => `<span class="source">${a ? `${txt(a.role)} · ${txt(a.run)}` : by === 'human' ? 'human, CLI outside any run' : 'broker'}</span>`;
|
||
const PRIORITY = ['unset', 'low', 'medium', 'high', 'urgent', 'do now'];
|
||
const head = (title, sub) => `<div class="page-head"><div><h1 tabindex="-1">${title}</h1>${sub ? `<p class="small muted" id="bus-status">${sub}</p>` : ''}</div></div>`;
|
||
let readAt = null;
|
||
const readNote = () => `Read from the bus ${when(readAt)}.`;
|
||
// Lead decision 63: what no Q1 read returns carries this label where it would show.
|
||
const gap = what => `<span class="feat">${esc(what)}: not in the Q1 module</span>`;
|
||
// Which choice authorizes which action on which target, as the broker recorded it at
|
||
// decision.raise. Never inferred from the recommendation or an option's position.
|
||
const approves = a => a ? `Choosing “${txt(a.approvalChoice)}” approves ${txt(a.action)}${a.target ? ` on ${txt(a.target)}` : ''}.` : 'No approval context was recorded for this decision, so no option is marked as approving.';
|
||
const first = q => String(q ?? '').split('\n')[0];
|
||
const due = d => d ? `<time datetime="${esc(d)}" title="${esc(d)}">${esc(String(d).slice(0, 10))}</time>` : '<span class="muted">none</span>';
|
||
const user = u => `user ${esc(u)}`;
|
||
|
||
// ---------------------------------------------------------------- views
|
||
let inboxCount = null;
|
||
function sections(route) {
|
||
const items = [['/', 'Board', '', 'board'], ['/inbox', 'Inbox', inboxCount ? `<span class="count" aria-label="${inboxCount.open} open for you">${inboxCount.open}</span>${inboxCount.blocking ? ` ${blockChip(`${inboxCount.blocking} blocking`)}` : ''}` : '', 'inbox'], ['/tasks', 'Tasks', '', 'tasks'], ['/agents', 'Agents', '', 'agents'], ['/queue', 'Queue', '', 'queue'], ['/business', 'Business', '', 'business'], ['/settings', 'Settings', '', 'settings']];
|
||
$('sections').innerHTML = items.map(([h, l, extra, icon]) => {
|
||
const cur = h === '/' ? route === '/' : route.startsWith(h) || (h === '/tasks' && route.startsWith('/trail/task')) || (h === '/inbox' && route.startsWith('/trail/decision'));
|
||
return `<li><a href="#${h}" class="s1-section"${cur ? ' aria-current="page"' : ''}><svg class="i" aria-hidden="true"><use href="/icons.svg#i-${icon}"/></svg>${l}${extra ? ` ${extra}` : ''}</a></li>`;
|
||
}).join('');
|
||
}
|
||
function countInbox(rows) { inboxCount = { open: rows.length, blocking: rows.filter(d => d.blocking).length }; }
|
||
|
||
// Inbox
|
||
function decisionCard(d) {
|
||
return `<li class="wait-item s1-dec${d.blocking ? ' is-attn' : ''}"><div class="wait-head"><a href="#/inbox/${enc(d.id)}" class="s1-dec-open">${txt(first(d.question))}</a></div>
|
||
<p class="s1-badges">${d.blocking ? blockChip('blocking') : ''}${chip(d.class, d.action)}</p>
|
||
<p class="small s1-auth">${approves(d.authorization)}</p>
|
||
<p class="small muted">Raised by ${txt(d.raised_by_role)} ${when(d.at)}${d.task_ref ? ` · task ${taskLink(d.task_ref)}` : ''}${d.requirement_ref ? ` · ${txt(d.requirement_ref)}` : ''}</p></li>`;
|
||
}
|
||
async function inboxView(get) {
|
||
// The broker orders gated first, then oldest; lift blocking ones to the top.
|
||
const rows = (await get('inbox')).slice().sort((a, b) => (b.blocking - a.blocking) || String(a.at).localeCompare(String(b.at)));
|
||
countInbox(rows);
|
||
const body = rows.length ? `<ul class="waiting s1-decs">${rows.map(decisionCard).join('')}</ul>` : empty('Nothing is waiting on you.', 'A decision shows here when an agent raises one routed to you through the bus.');
|
||
return `${head('Inbox', `${readNote()} Decisions are answered in a terminal with <code>mosaic decide</code>; Console only shows them.`)}
|
||
<h2 id="b-for-you">For you <span class="count">${rows.length}</span></h2>${body}
|
||
<p class="small muted">Not shown here yet: ${gap('Decisions routed to roles')} ${gap('Closed decisions')} ${gap('Seen and DM state per row')} Open a decision for its seen, DM and closing history.</p>`;
|
||
}
|
||
async function decisionView(get, id) {
|
||
const open = (await get('inbox')).find(r => r.id === id) || null, d = await decision(get, id, open);
|
||
if (!d) return notFound(`No decision with id ${txt(id)}.`);
|
||
const c = d.closed;
|
||
const status = c ? (c.op === 'resolved' ? `Resolved: ${txt(c.by)} chose “${txt(c.choice)}”${c.via ? ` via ${txt(c.via)}` : ''} ${when(c.at)}.` : `${esc(c.op[0].toUpperCase() + c.op.slice(1))} by ${txt(c.by)} ${when(c.at)}${c.note ? `: ${txt(c.note)}` : ''}.`) : `Open since ${when(d.at)}.`;
|
||
// A command is offered only for an open decision routed to the human, and only when
|
||
// the id and key pass the broker's identifier rule, so the copied text is safe to paste.
|
||
const answerable = !c && d.routeTo === 'human';
|
||
const options = `<ol class="s1-opts">${d.options.map(o => {
|
||
const cmd = `mosaic decide ${d.id} ${o.key}`, chosen = c?.choice === o.key, approving = d.authorization?.approvalChoice === o.key;
|
||
const copy = !answerable ? '' : SAFE.test(String(d.id)) && SAFE.test(String(o.key))
|
||
? `<div class="s1-cmd"><code>${txt(cmd)}</code><button type="button" class="btn" data-copy="${esc(cmd)}" aria-label="Copy the command for option ${txt(o.key)}">Copy</button></div>`
|
||
: '<p class="small muted">This option has no command here: its key is not a plain identifier.</p>';
|
||
return `<li class="s1-opt"><p><b class="s1-key">${txt(o.key)}</b> ${txt(o.text)} ${approving ? badge(`approves ${d.authorization.action}`, 'attn') : ''}${o.key === d.recommendation ? badge('recommended', 'ok') : ''}${chosen ? badge('chosen', 'ok') : ''}</p>${copy}</li>`;
|
||
}).join('')}</ol>`;
|
||
const earlier = await chain(get, d);
|
||
const history = [...d.seen.map(s => `<li>Seen by ${txt(s.by)}${s.via ? ` via ${txt(s.via)}` : ''} ${when(s.at)}</li>`), ...(c ? [`<li>${esc(c.op)} by ${txt(c.by)}${c.via ? ` via ${txt(c.via)}` : ''} ${when(c.at)}</li>`] : [])];
|
||
const help = answerable ? '<p class="small muted">To answer, run one of these in a terminal. Copy only puts the command on your clipboard. Console sends nothing.</p>'
|
||
: !c ? `<p class="small muted">This decision is routed to ${txt(d.routeTo)}, not to you, so Console offers no command for it.</p>` : '';
|
||
return `${head(`Decision ${txt(short(d.id))}`, status)}
|
||
<section aria-labelledby="b-q" class="panel"><h2 id="b-q">Question</h2><p class="s1-q">${txt(d.question)}</p>
|
||
<dl class="kv"><dt>Class</dt><dd>${chip(d.class, d.action)}${d.blocking ? ` ${blockChip('blocking')}` : ''}</dd><dt>Approval</dt><dd class="s1-auth">${approves(d.authorization)}</dd><dt>Routed to</dt><dd>${txt(d.routeTo)}</dd><dt>Raised by</dt><dd>${txt(d.raisedBy.role)} <span class="source">${txt(d.raisedBy.run)}</span></dd><dt>Raised</dt><dd>${when(d.at)}</dd>${d.taskRef ? `<dt>Task</dt><dd>${taskLink(d.taskRef)} <span class="source">${txt(d.taskRef)}</span></dd>` : ''}${d.requirementRef ? `<dt>Requirement</dt><dd>${txt(d.requirementRef)}</dd>` : ''}<dt>DM</dt><dd>${d.dm ? `sent ${when(d.dm.at)}` : d.blocking ? 'not sent' : 'not needed (not blocking)'}</dd><dt>Id</dt><dd><code>${txt(d.id)}</code></dd></dl></section>
|
||
<section aria-labelledby="b-o"><h2 id="b-o">Options</h2>${help}${options}<p class="small muted" id="copy-status"></p></section>
|
||
<section aria-labelledby="b-c"><h2 id="b-c">Earlier versions</h2>${earlier.length ? `<ul class="s1-plain">${earlier.map(x => `<li><a href="#/inbox/${enc(x.id)}">${txt(short(x.id))}</a> ${badge(x.closed ? x.closed.op : 'open')} ${txt(first(x.question))}${x.closed?.note ? `<span class="source">${txt(x.closed.note)}</span>` : ''}</li>`).join('')}</ul>` : '<p class="muted">None.</p>'}<p class="small muted">${gap('A later version that replaces this one')}</p></section>
|
||
<section aria-labelledby="b-h"><h2 id="b-h">History</h2>${history.length ? `<ul class="s1-plain">${history.join('')}</ul>` : '<p class="muted">Not seen yet.</p>'}<p><a href="#/trail/decision/${enc(d.id)}">Full trail for this decision</a></p></section>`;
|
||
}
|
||
|
||
// Tasks
|
||
function markers(t) {
|
||
const out = [];
|
||
// The list has no trail, so it can only say the current state came from a Vikunja read;
|
||
// the task page tells a change to a task the stack wrote from one it never wrote.
|
||
if (t.external) out.push(t.external.kind === null ? badge(`last read from Vikunja (${t.external.via})`, 'attn') : t.external.kind === 'foreign' ? badge('not created by the stack', 'attn') : badge(`changed in Vikunja${t.external.changed.length ? `: ${t.external.changed.join(', ')}` : ''}`, 'attn'));
|
||
if (t.conflicts.length) out.push(badge(plural(t.conflicts.length, 'refused write'), 'danger'));
|
||
if (t.missing) out.push(badge(`missing: ${t.missing.reason}`, 'danger'));
|
||
return out.join(' ');
|
||
}
|
||
const title = t => `#${esc(t.id)} ${t.title === null ? '<span class="muted">(no title in the current read)</span>' : txt(t.title)}`;
|
||
const priority = p => p === null ? '<span class="muted">none</span>' : esc(PRIORITY[p] || p);
|
||
function taskRow(t) {
|
||
return `<tr><td class="s1-title"><a href="#/tasks/${enc(t.ref)}">${title(t)}</a></td><td>${t.requirement ? txt(t.requirement) : '<span class="muted">none</span>'}</td><td>${t.assignees.map(user).join(', ') || '<span class="muted">none</span>'}</td><td>${due(t.due)}</td><td>${priority(t.priority)}</td><td>${esc(t.percent)}%</td><td>${when(t.changedAt)}</td><td>${markers(t) || '<span class="muted">none</span>'}</td></tr>`;
|
||
}
|
||
const taskTable = (rows, label) => `<div class="table-wrap" tabindex="0" role="region" aria-label="${esc(label)}, scroll for all columns"><table class="s1-table s1-tasks"><thead><tr>${['Task', 'Requirement', 'Assignees', 'Due', 'Priority', 'Done', 'Changed', 'Markers'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows.map(taskRow).join('')}</tbody></table></div>`;
|
||
function freshness(rows) {
|
||
const reads = rows.filter(r => r.source === 'poll' && r.read_at).map(r => r.read_at).sort();
|
||
return `Newest Vikunja read among these tasks: ${reads.length ? when(reads.at(-1)) : 'none'}. <span class="feat">Poller status: from the S3 status read, not built yet</span>`;
|
||
}
|
||
const byChanged = (x, y) => String(y.changedAt).localeCompare(String(x.changedAt));
|
||
async function tasksView(get) {
|
||
const rows = await get('tasks'), ts = rows.map(r => task(r));
|
||
if (!ts.length) return `${head('Tasks', freshness(rows))}${empty('No tasks in this business yet.', 'Tasks appear after the PM creates them or the poller reads them from Vikunja.')}`;
|
||
const open = ts.filter(t => !t.missing && !t.done), done = ts.filter(t => !t.missing && t.done).sort(byChanged), missing = ts.filter(t => t.missing);
|
||
const buckets = [...new Set(open.map(t => t.bucket))].sort((a, b) => a - b);
|
||
const groups = buckets.map(b => {
|
||
const g = open.filter(t => t.bucket === b).sort(byChanged), id = `b-bucket-${esc(b)}`;
|
||
return `<section aria-labelledby="${id}"><h2 id="${id}">Bucket ${esc(b)} <span class="count">${g.length}</span></h2>${taskTable(g, `Bucket ${b} tasks`)}</section>`;
|
||
}).join('');
|
||
const doneHtml = done.length ? `<details class="s1-done" id="b-done"><summary><h2 id="b-done-title">Done <span class="count">${done.length}</span></h2></summary>${taskTable(done.slice(0, 50), 'Done tasks')}${done.length > 50 ? `<p class="small muted">The 50 most recently changed of ${done.length}.</p>` : ''}</details>` : '';
|
||
const missingHtml = missing.length ? `<section aria-labelledby="b-missing"><h2 id="b-missing">No longer readable <span class="count">${missing.length}</span></h2><ul class="s1-plain">${missing.map(t => `<li><a href="#/tasks/${enc(t.ref)}">${title(t)}</a> ${badge(`missing: ${t.missing.reason}`, 'danger')} <span class="source">since ${clock(t.missing.at)}</span></li>`).join('')}</ul></section>` : '';
|
||
return `${head('Tasks', freshness(rows))}<p class="small muted">Edit tasks in Vikunja; Console only reads them. ${gap('Bucket names, bot names and the Vikunja address')}</p>${groups || empty('No open tasks.', 'Open tasks are read from Vikunja; done and unreadable ones are listed below.')}${doneHtml}${missingHtml}`;
|
||
}
|
||
async function taskView(get, ref) {
|
||
const row = (await get('tasks')).find(r => r.task_ref === ref);
|
||
if (!row) return notFound(`No task ${txt(ref)} in this business.`);
|
||
const tr = await get('trail', ref), t = task(row, tr);
|
||
const ext = t.external ? `<dt>Vikunja</dt><dd>${t.external.kind === 'foreign' ? 'Not created by the stack; first read' : 'Changed in Vikunja after the stack wrote it'}${t.external.changed.length ? `: ${txt(t.external.changed.join(', '))}` : ''}${t.external.comments ? `, ${esc(plural(t.external.comments, 'new comment'))}` : ''} <span class="source">${txt(t.external.via)} read sent ${clock(t.external.readAt)}</span></dd>` : '';
|
||
const conflicts = t.conflicts.length ? `<dt>Refused writes</dt><dd>${t.conflicts.map(c => `${txt(c.verb)}: Vikunja changed after the stack read it (expected ${txt(short(c.expected))}, found ${txt(short(c.actual))}) ${when(c.at)}`).join('<br>')}</dd>` : '';
|
||
const decisions = t.decisions.length ? `<dt>Decisions</dt><dd>${t.decisions.map(d => `<a href="#/inbox/${enc(d.id)}">${txt(short(d.id))}</a> ${txt(first(d.question))}`).join('<br>')}</dd>` : '';
|
||
const sub = t.missing ? `No longer readable in Vikunja (${txt(t.missing.reason)}${t.missing.project ? `, now in project ${txt(t.missing.project)}` : ''}) since ${when(t.missing.at)}.` : `${t.done ? 'Done' : `In bucket ${esc(t.bucket)}`}, last changed ${when(t.changedAt)}. ${readNote()}`;
|
||
return `${head(title(t), `${sub} ${markers(t)}`)}
|
||
<section class="panel" aria-labelledby="b-t"><h2 id="b-t">Task</h2><dl class="kv"><dt>Ref</dt><dd><code>${txt(t.ref)}</code> ${gap('The Vikunja address')}</dd><dt>Assignees</dt><dd>${t.assignees.map(user).join(', ') || 'none'} ${gap('Bot names')}</dd><dt>Requirement</dt><dd>${txt(t.requirement || 'none')}</dd><dt>Due</dt><dd>${due(t.due)}</dd><dt>Priority</dt><dd>${priority(t.priority)}</dd><dt>Done</dt><dd>${esc(t.percent)}%</dd>${ext}${conflicts}${decisions}</dl>
|
||
${t.description ? `<h3>Description</h3><p class="s1-q">${txt(t.description)}</p>` : ''}
|
||
<details class="s1-snaps" id="b-snaps"><summary>Snapshots <span class="count">${t.snapshots.length}</span></summary><ol class="s1-plain">${t.snapshots.map(s => `<li>${clock(s.at)} ${esc(bucketText(s.fields))} <span class="source">${s.source === 'self' ? `written by ${txt(s.role)}` : `${txt(s.via)} read sent ${esc(String(s.readAt).slice(11, 19))}`}${s.stale ? '; stale read, not shown' : ''}</span></li>`).join('')}</ol></details></section>
|
||
<section aria-labelledby="b-trail"><h2 id="b-trail">Trail</h2>${trailList(lines(tr, row.seq), 'all')}<p><a href="#/trail/task/${enc(ref)}">Trail with filters</a></p></section>`;
|
||
}
|
||
|
||
// Agents
|
||
// Row 59 (#1548): the state mark is the control board's (shell.css), glyph and word. The
|
||
// broker derives the state from its own records: working while the holder has messages it
|
||
// received and hasn't answered, waiting while a decision the role raised waits for the
|
||
// human, otherwise idle.
|
||
const stateMark = state => `<span class="badge st st-${esc(state || 'unknown')} badge-${{ working: 'ok', waiting: 'attn' }[state] || 'muted'}">${esc(state || 'unknown')}</span>`;
|
||
function attention(c) {
|
||
const asks = Array.isArray(c.inputNeeded) ? c.inputNeeded : [];
|
||
if (!asks.length) return none;
|
||
const d = asks[0];
|
||
return `Input needed: <a href="#/inbox/${enc(d.id)}">${txt(first(d.question))}</a> <span class="source">${txt(short(d.id))}</span>${d.blocking ? ` ${blockChip('blocking')}` : ''}${asks.length > 1 ? ` <a href="#/inbox">+${esc(asks.length - 1)} more</a>` : ''}`;
|
||
}
|
||
async function agentsView(get) {
|
||
const claims = await get('agents');
|
||
const rows = claims.map(c => `<tr><th scope="row">${txt(c.role)}</th><td>${stateMark(c.state)}${c.working ? `<span class="source">${esc(plural(c.working.messages, 'message'))} since ${when(c.working.since)}</span>` : ''}</td><td>${attention(c)}</td><td>${txt(c.holder_run)}</td><td>${txt(c.harness)}${c.address ? `<span class="source">${txt(c.address)}</span>` : ''}</td><td>${when(c.at)}</td></tr>`).join('');
|
||
return `${head('Agents', readNote())}
|
||
<section aria-labelledby="b-claims"><h2 id="b-claims">Held roles <span class="count">${claims.length}</span></h2><p class="small muted">The current holder of each claimed role. A role nobody holds does not appear. Working: the holder has messages it received and hasn't answered yet. Waiting: a decision the role raised is open in your inbox. Otherwise idle.</p>
|
||
${claims.length ? `<div class="table-wrap" tabindex="0" role="region" aria-label="Held roles, scroll for all columns"><table class="s1-table s1-agents"><thead><tr>${['Role', 'State', 'Attention', 'Holder', 'Harness', 'Held since'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div>` : empty('No role is held right now.', 'A role shows here while a run holds its claim on the bus.')}</section>
|
||
<p class="small muted">Not shown here yet: ${gap('Role instances, launch limits and model families')} ${gap('Launch state')} ${gap('Live and ended sessions')} ${gap('Credential notices')}</p>
|
||
<p class="small muted">Board seats stay on the <a href="#/">control board</a>. This view is about roles.</p>`;
|
||
}
|
||
|
||
// ---------------------------------------------------------------- queue, business, settings
|
||
// Row 54 (#1543), read-only. A row moves only with `scripts/mosaic queue move` in a terminal,
|
||
// so its page shows that command and has no button for it. The business file is edited where
|
||
// it lives. On Settings only appearance changes, and only in this browser.
|
||
const readLine = () => `Read ${when(readAt)}.`;
|
||
const none = '<span class="muted">none</span>';
|
||
const QUEUE_STATES = ['queued', 'briefed', 'in-progress', 'in-review', 'waiting-on-jason', 'done', 'blocked', 'parked'];
|
||
const QSTATE = { done: 'ok', blocked: 'danger', 'waiting-on-jason': 'attn', 'in-review': 'attn', 'in-progress': 'attn' };
|
||
const qState = s => badge(s, QSTATE[s] || 'muted');
|
||
const qLink = id => Number.isInteger(id) ? `<a href="#/queue/${esc(id)}">${esc(id)}</a>` : txt(id);
|
||
// An after entry is {id, when}, as QUEUE.md writes it: "53 done".
|
||
const afterRef = a => a && typeof a === 'object' ? `${qLink(a.id)}${a.when ? ` ${txt(a.when)}` : ''}` : qLink(a);
|
||
const names = list => Array.isArray(list) && list.length ? list.map(txt).join(', ') : none;
|
||
// The business file's arbiters are {delivery, technical}, each a role instance: "pm (delivery)".
|
||
const arbiterRefs = a => a && typeof a === 'object' && !Array.isArray(a) ? Object.entries(a).map(([kind, who]) => `${txt(who)} (${txt(kind)})`).join(', ') || none : names(a);
|
||
const issueRefs = list => Array.isArray(list) && list.length ? list.map(n => `#${txt(n)}`).join(', ') : none;
|
||
// Console serves no repository files, so the brief is its path and anchor, as the row names it.
|
||
const briefRef = b => b?.path ? `<code>${txt(b.path)}${b.anchor ? `#${txt(b.anchor)}` : ''}</code>` : none;
|
||
const storeNotes = notes => Array.isArray(notes) && notes.length ? `<section aria-labelledby="q-notes"><h2 id="q-notes">From the store <span class="count">${notes.length}</span></h2><ul class="s1-plain">${notes.map(n => `<li>${txt(n)}</li>`).join('')}</ul></section>` : '';
|
||
const moveHelp = 'Rows move only with <code>scripts/mosaic queue move</code> in a terminal. Console has no button for it.';
|
||
function queueRow(r) {
|
||
return `<tr><td class="s1-title"><a href="#/queue/${esc(r.id)}">${esc(r.id)} ${txt(r.piece)}</a></td><td>${qState(r.state)}</td><td>${txt(r.owner) || none}</td><td>${names(r.reviewers)}</td><td>${briefRef(r.brief)}</td><td>${when(r.updatedAt)}</td></tr>`;
|
||
}
|
||
async function queueView(get, q) {
|
||
const doc = await get.api('queue'), rows = Array.isArray(doc.rows) ? doc.rows : [];
|
||
const filter = QUEUE_STATES.includes(q.get('state')) ? q.get('state') : 'all';
|
||
const shown = filter === 'all' ? rows : rows.filter(r => r.state === filter);
|
||
const chips = `<nav aria-label="Filter the queue by state" class="chips">${['all', ...QUEUE_STATES].map(s => `<a class="chip" href="#/queue${s === 'all' ? '' : `?state=${s}`}"${filter === s ? ' aria-current="true"' : ''}>${s === 'all' ? 'All' : esc(s)} <span class="count">${s === 'all' ? rows.length : rows.filter(r => r.state === s).length}</span></a>`).join('')}</nav>`;
|
||
const table = `<div class="table-wrap" tabindex="0" role="region" aria-label="Queue rows, scroll for all columns"><table class="s1-table s1-queue"><thead><tr>${['Row', 'State', 'Owner', 'Reviewers', 'Brief', 'Updated'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${shown.map(queueRow).join('')}</tbody></table></div>`;
|
||
const body = !rows.length ? empty('The queue has no rows.', 'A row shows here after <code>scripts/mosaic queue add</code> records it in <code>docs/plans/queue.json</code>.')
|
||
: shown.length ? table : empty(`No row is ${esc(filter)}.`, 'Choose another state above.');
|
||
return `${head('Queue', `${readLine()} ${moveHelp}`)}${storeNotes(doc.notes)}${rows.length ? chips : ''}${body}`;
|
||
}
|
||
const round = r => {
|
||
const receipts = Array.isArray(r.receipts) ? r.receipts : [];
|
||
return `<li><p><b>Round ${txt(r.n)}</b> requested by ${txt(r.by)} ${when(r.at)}${r.issue ? ` on #${txt(r.issue)}` : ''}${r.candidate ? ` · candidate ${txt(r.candidate.kind)} <code>${txt(String(r.candidate.digest ?? '').slice(0, 12))}</code>` : ''}</p>
|
||
${receipts.length ? `<ul class="s1-plain">${receipts.map(x => `<li>${txt(x.reviewer)}: ${badge(x.verdict ?? 'no verdict', x.verdict === 'approve' ? 'ok' : x.verdict ? 'attn' : 'muted')} ${when(x.at)}${x.comment ? ` <span class="source">comment ${txt(x.comment)}</span>` : ''}</li>`).join('')}</ul>` : '<p class="small muted">No verdict yet.</p>'}</li>`;
|
||
};
|
||
async function queueRowView(get, id) {
|
||
let doc;
|
||
try { doc = await get.api(`queue/${id}`); } catch (err) { if (err.code === 'not-found') return notFound(`No row ${esc(id)} in the queue.`); throw err; }
|
||
const r = doc.row || {}, rounds = Array.isArray(r.review?.rounds) ? r.review.rounds : [];
|
||
// The command names this row; the state, op and seat are the person's to fill in.
|
||
const cmd = `scripts/mosaic queue move ${id} <state> --op <op> --by <seat>`;
|
||
return `${head(`Row ${esc(id)}: ${txt(r.piece)}`, `${qState(r.state)}${r.previousState ? ` <span class="small muted">was ${txt(r.previousState)}</span>` : ''} ${readLine()}`)}
|
||
${storeNotes(doc.notes)}
|
||
<section class="panel" aria-labelledby="q-row"><h2 id="q-row">Row</h2><dl class="kv"><dt>Owner</dt><dd>${txt(r.owner) || none}</dd><dt>Reviewers</dt><dd>${names(r.reviewers)}</dd><dt>Gate</dt><dd>${txt(r.gateOwner) || none}</dd><dt>Issues</dt><dd>${issueRefs(r.issues)}</dd><dt>Closes</dt><dd>${issueRefs(r.closes)}</dd><dt>Brief</dt><dd>${briefRef(r.brief)}${r.brief?.blob ? ` <span class="source">blob ${txt(String(r.brief.blob).slice(0, 12))}</span>` : ''}</dd><dt>After</dt><dd>${Array.isArray(r.after) && r.after.length ? r.after.map(afterRef).join(', ') : none}</dd><dt>Claim</dt><dd>${r.claim ? `${txt(r.claim.seat)} <span class="source">${txt(r.claim.op)}</span>` : none}</dd>${r.required ? `<dt>Required</dt><dd>yes${Number.isFinite(Date.parse(r.requiredSince)) ? ` since ${when(r.requiredSince)}` : ''}</dd>` : ''}${r.blockedReason ? `<dt>Blocked</dt><dd>${txt(r.blockedReason)}</dd>` : ''}${r.note ? `<dt>Note</dt><dd class="s1-q">${txt(r.note)}</dd>` : ''}<dt>Created</dt><dd>${when(r.createdAt)}</dd><dt>Updated</dt><dd>${when(r.updatedAt)}${r.updatedBy ? ` by ${txt(r.updatedBy)}` : ''}</dd></dl></section>
|
||
<section aria-labelledby="q-review"><h2 id="q-review">Review <span class="count">${rounds.length}</span></h2>${rounds.length ? `<ol class="s1-plain">${rounds.map(round).join('')}</ol>` : '<p class="muted">No review round yet.</p>'}</section>
|
||
<section aria-labelledby="q-move"><h2 id="q-move">Moving this row</h2><p class="small muted">${moveHelp} Copy only puts the command on your clipboard; fill in the state, op and seat before you run it.</p>
|
||
<div class="s1-cmd"><code>${txt(cmd)}</code><button type="button" class="btn" data-copy="${esc(cmd)}" aria-label="Copy the move command for row ${esc(id)}">Copy</button></div><p class="small muted" id="copy-status"></p>
|
||
<p><a href="#/queue">Back to the queue</a></p></section>`;
|
||
}
|
||
|
||
const CRED = { valid: 'ok', expiring: 'attn', expired: 'danger', 'rotation-due': 'danger' };
|
||
const AUTH = { within: 'within-role', cross: 'cross-role', gated: 'gated' };
|
||
const kvRows = (vars, label) => {
|
||
const e = Object.entries(vars || {});
|
||
return e.length ? `<dl class="kv">${e.map(([k, v]) => `<dt><code>${txt(k)}</code></dt><dd>${txt(Array.isArray(v) ? v.join(', ') : typeof v === 'object' && v !== null ? JSON.stringify(v) : v)}</dd>`).join('')}</dl>` : `<p class="muted">${label}</p>`;
|
||
};
|
||
async function businessView(get) {
|
||
const b = (await get.api('business')).business || {};
|
||
const instances = Array.isArray(b.instances) ? b.instances : [], creds = Array.isArray(b.credentials) ? b.credentials : [], actions = Array.isArray(b.actions) ? b.actions : [];
|
||
const launch = b.launch || {};
|
||
const authKnown = instances.some(i => i.authority);
|
||
const roleRows = instances.map(i => `<tr><th scope="row">${txt(i.instance)}</th><td>${txt(i.definition)}</td><td>${txt(i.holder) || none}</td><td>${txt(i.vars?.harness) || none}${i.vars?.model ? `<span class="source">${txt(i.vars.model)}</span>` : ''}</td></tr>`).join('');
|
||
const matrix = authKnown ? `<div class="table-wrap" tabindex="0" role="region" aria-label="Authority by action, scroll for all instances"><table class="s1-table s1-authority"><thead><tr><th scope="col">Action</th>${instances.map(i => `<th scope="col">${txt(i.instance)}</th>`).join('')}</tr></thead><tbody>${actions.map(a => `<tr><th scope="row"><code>${txt(a)}</code></th>${instances.map(i => { const c = i.authority?.[a]; return `<td>${c ? `<span class="tag tag-${AUTH[c] || 'gated'}">${txt(c)}</span>` : '<span class="muted">refused</span>'}</td>`; }).join('')}</tr>`).join('')}</tbody></table></div>`
|
||
: '<p class="muted">Authority comes from the system config with the business file. This Console could not read the system config, so it shows none.</p>';
|
||
const refused = instances.filter(i => i.refused).map(i => `<li>${txt(i.instance)}: ${txt(i.refused)}</li>`).join('');
|
||
const credRows = creds.map(c => `<tr><td>${txt(c.service)}</td><td>${txt(c.account) || '<span class="muted">not named in the business file</span>'}</td><td>${txt(c.role)}</td><td>${txt(c.dateKind === 'expires' ? 'expires' : 'rotate by')} <time datetime="${esc(c.date)}">${txt(c.date)}</time></td><td>${badge(c.state, CRED[c.state] || 'muted')}</td></tr>`).join('');
|
||
return `${head(`Business ${txt(b.id)}`, `${readLine()} The business file is edited where it lives; Console only reads it.`)}
|
||
<section class="panel" aria-labelledby="bz-about"><h2 id="bz-about">Business</h2><dl class="kv"><dt>Human</dt><dd>${txt(b.human) || none}</dd><dt>Arbiters</dt><dd>${arbiterRefs(b.arbiters)}</dd><dt>Projects</dt><dd>${names(b.projects)}</dd><dt>Launcher</dt><dd>${txt(launch.by) || none}</dd><dt>May launch</dt><dd>${names(launch.instances)}</dd><dt>At most</dt><dd>${Object.entries(launch.max || {}).map(([f, c]) => `${txt(c)} ${txt(f)}`).join(', ') || none}</dd></dl>
|
||
<h3>Vars</h3>${kvRows(b.vars, 'No vars Console shows are set.')}</section>
|
||
<section aria-labelledby="bz-roles"><h2 id="bz-roles">Role instances <span class="count">${instances.length}</span></h2>${instances.length ? `<div class="table-wrap" tabindex="0" role="region" aria-label="Role instances, scroll for all columns"><table class="s1-table s1-roles"><thead><tr>${['Instance', 'Definition', 'Holder', 'Harness'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${roleRows}</tbody></table></div>` : empty('This business has no role instances.', 'An instance shows here when the business file names one under roles.')}</section>
|
||
${instances.length ? `<section aria-labelledby="bz-auth"><h2 id="bz-auth">Authority</h2><p class="small muted">Within the role, cross-role (needs the other role), or gated (needs the human), as the business package classifies each action.</p>${matrix}${refused ? `<ul class="s1-plain">${refused}</ul>` : ''}</section>` : ''}
|
||
<section aria-labelledby="bz-creds"><h2 id="bz-creds">Credentials <span class="count">${creds.length}</span></h2><p class="small muted">Metadata only: service, account, role and date. Console never reads a token, a token file or a variable.</p>${creds.length ? `<div class="table-wrap" tabindex="0" role="region" aria-label="Credentials, scroll for all columns"><table class="s1-table s1-creds"><thead><tr>${['Service', 'Account', 'Role', 'Date', 'State'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${credRows}</tbody></table></div>` : empty('No credentials are named.', 'A credential shows here when a role instance or tracker sync names one.')}</section>`;
|
||
}
|
||
|
||
// Appearance mirrors the command bar's two selects, so app.js keeps the one copy of the rule.
|
||
function appearance() {
|
||
const p = $('palette'), m = $('mode');
|
||
const pick = (id, label, src) => `<label>${label}<select data-mirror="${id}">${[...src.options].map(o => `<option value="${esc(o.value)}"${o.value === src.value ? ' selected' : ''}>${esc(o.textContent)}</option>`).join('')}</select></label>`;
|
||
return `<section aria-labelledby="st-look" class="panel"><h2 id="st-look">Appearance</h2><p class="small muted">Stored in this browser only. The same control is in the command bar.</p><div class="filters">${pick('palette', 'Palette', p)}${pick('mode', 'Appearance', m)}</div></section>`;
|
||
}
|
||
async function settingsView(get) {
|
||
const s = await get.api('settings'), n = s.notifier || {};
|
||
const notify = typeof n.binding === 'string'
|
||
? `<dl class="kv"><dt>Binding</dt><dd><code>${txt(n.binding)}</code></dd></dl><p class="small muted">The binding name only. Its channels, server and user ids stay in the binding file.</p>`
|
||
: empty('No notifier binding to show.', `${n.refused ? `<code>${txt(n.refused)}</code>: ` : ''}${txt(n.message || 'the notifier settings could not be read')}`);
|
||
return `${head('Settings', `${readLine()} Only appearance changes here.`)}${appearance()}
|
||
<section aria-labelledby="st-notify"><h2 id="st-notify">Notifications</h2>${notify}</section>
|
||
<section aria-labelledby="st-about" class="panel"><h2 id="st-about">About</h2><dl class="kv"><dt>Address</dt><dd><code>${txt(s.address) || 'unknown'}</code></dd><dt>Release</dt><dd>${s.release ? `<code>${txt(s.release)}</code>` : '<span class="muted">unknown: no RELEASE file was read</span>'}</dd><dt>Business</dt><dd>${txt(s.business) || none}</dd><dt>Board</dt><dd><code>${txt(s.board) || 'unknown'}</code></dd></dl></section>`;
|
||
}
|
||
|
||
// Trail
|
||
const GROUPS = [['all', 'All'], ['request', 'Requests'], ['decision', 'Decisions'], ['launch', 'Launches'], ['task', 'Task changes'], ['review', 'Review']];
|
||
function trailList(rows, filter) {
|
||
const shown = filter === 'all' ? rows : rows.filter(r => r.group === filter);
|
||
if (!shown.length) return empty('Nothing of this kind in the trail.', 'Every step the stack takes on this subject writes a trail row.');
|
||
return `<ol class="s1-trail">${shown.map(r => `<li class="s1-trow s1-g-${esc(r.group)}"><span class="s1-twhen">${clock(r.at)}</span><span class="s1-tkind">${txt(r.kind)}</span><div class="s1-ttext"><p>${txt(r.text)}${r.decision && !r.kind.startsWith('decision.') && !r.kind.startsWith('delivery.') && SAFE.test(String(r.decision)) ? ` <a href="#/inbox/${enc(r.decision)}">decision ${txt(short(r.decision))}</a>` : ''}</p>${actor(r.actor, r.by)}${r.note ? `<span class="source">${esc(r.note)}</span>` : ''}</div></li>`).join('')}</ol>`;
|
||
}
|
||
async function trailView(get, kind, ref, q) {
|
||
let rows;
|
||
if (kind === 'task') {
|
||
const row = (await get('tasks')).find(r => r.task_ref === ref);
|
||
if (!row) return notFound(`No task ${txt(ref)} in this business.`);
|
||
rows = lines(await get('trail', ref), row.seq);
|
||
} else {
|
||
const d = await decision(get, ref);
|
||
if (!d) return notFound(`No decision with id ${txt(ref)}.`);
|
||
// A decision's trail with its earlier versions, merged in the broker's order.
|
||
const all = new Map();
|
||
for (const x of [d, ...await chain(get, d)]) for (const r of x.trail) all.set(`${r.table}:${r.seq}`, r);
|
||
rows = lines([...all.values()].sort((a, b) => String(a.at).localeCompare(String(b.at)) || String(a.table).localeCompare(String(b.table)) || a.seq - b.seq));
|
||
}
|
||
const filter = GROUPS.some(([g]) => g === q.get('kind')) ? q.get('kind') : 'all';
|
||
const base = `#/trail/${kind}/${enc(ref)}`;
|
||
const name = kind === 'task' ? `Trail for task #${esc(String(ref).split('/')[1])}` : `Trail for decision ${txt(short(ref))}`;
|
||
const back = kind === 'task' ? `<a href="#/tasks/${enc(ref)}">Back to the task</a>` : `<a href="#/inbox/${enc(ref)}">Back to the decision</a>`;
|
||
return `${head(name, `${readNote()} Oldest first. ${back}`)}
|
||
<nav aria-label="Filter the trail" class="chips">${GROUPS.map(([g, l]) => `<a class="chip" href="${base}${g === 'all' ? '' : `?kind=${g}`}"${filter === g ? ' aria-current="true"' : ''}>${l} <span class="count">${g === 'all' ? rows.length : rows.filter(r => r.group === g).length}</span></a>`).join('')}</nav>
|
||
${trailList(rows, filter)}`;
|
||
}
|
||
|
||
// Not found and states
|
||
function notFound(why) { return `${head('Not found')}<div class="empty nf"><b>${why || 'No page at this address.'}</b><p>Nothing is at <code>${txt(location.hash || '#/')}</code>. <a href="#/">Go to the Board</a> or press <kbd>Ctrl K</kbd> to search.</p></div>`; }
|
||
const loadingView = (source = null) => `${head('Reading…')}<div class="skel-rows" aria-busy="true"><span class="sr-only">${source ? `Reading the ${esc(source.toLowerCase())}.` : 'Reading from the bus.'}</span><span class="skel"></span><span class="skel"></span><span class="skel"></span><span class="skel"></span></div>`;
|
||
const retry = '<div class="actions"><button type="button" class="btn" data-retry>Read again</button></div>';
|
||
// Why a read failed, in words, with the bus code as given. Never a path or a guess.
|
||
function why(err) {
|
||
const code = `<code>${txt(err.code)}</code>`;
|
||
// The queue, business and settings reads: the module's own text, redacted by the server.
|
||
if (err.module) {
|
||
const said = {
|
||
'queue-refused': 'The queue refused the read.',
|
||
'not-configured': 'Console could not load what this view reads.',
|
||
'no-bus-host': 'No bus host is running, and the Console was started without <code>--business</code>, so it knows no business. Start one with <code>mosaic bus start <business></code>.',
|
||
'read-failed': 'The read did not finish.',
|
||
'invalid-request': 'The Console server refused this address.',
|
||
unreachable: 'The Console server did not answer.',
|
||
}[err.code] || 'The read answered with something Console cannot use.';
|
||
return `${said} (${code})${err.code !== 'unreachable' && err.message ? `<br><span class="source">${txt(err.message)}</span>` : ''}`;
|
||
}
|
||
const say = {
|
||
'human-required': 'The bus answers the Console only when the human started it from their own shell. This Console was started inside an agent session, so the bus refused.',
|
||
unauthenticated: 'The bus did not accept this Console as the human.',
|
||
'unknown-business': 'The bus does not know this business.',
|
||
'read-only': 'The bus refused the read.',
|
||
'not-configured': 'This Console has no bus configured: the system config could not be read when it started. The control board still works.',
|
||
'no-bus-host': 'No bus host is running, and the Console was started without <code>--business</code>. Start one with <code>mosaic bus start <business></code>.',
|
||
'outcome-unknown': 'The bus did not answer in time.',
|
||
'invalid-request': 'The bus refused this address as a request.',
|
||
unreachable: 'The Console server did not answer.',
|
||
}[err.code] || 'The bus answered with something Console cannot use.';
|
||
return `${say} (${code})`;
|
||
}
|
||
const failTitle = err => err.code === 'queue-refused' ? 'Queue refused the read' : err.status === 403 ? 'Bus refused the read' : ['not-configured', 'no-bus-host'].includes(err.code) ? (err.module ? 'Nothing to read' : 'No bus to read') : 'The read failed';
|
||
// A refusal fails closed: no kept data, the code in mono. Any other failure keeps the last read, labelled with its time.
|
||
const refusal = err => err.status === 403 || ['not-configured', 'no-bus-host', 'queue-refused'].includes(err.code);
|
||
// After a refusal nothing read before is shown anywhere: not in a view, the palette or the Inbox count.
|
||
function forget() { last.clear(); inboxCount = null; }
|
||
const failedView = err => `${head(failTitle(err))}<div class="banner ${refusal(err) ? 'ref' : 'err'}" role="alert"><p>${refusal(err) ? `<b>refused: <code>${txt(err.code)}</code>.</b> ` : ''}${why(err)} Console shows nothing rather than a guess. Nothing was written.</p>${retry}</div>${empty('Nothing to show.', refusal(err) ? 'The view stays empty until the problem above is fixed.' : err.module ? 'Read again.' : 'Read again, or come back when the bus answers.')}`;
|
||
const staleBanner = err => `<div class="banner err s1-stale" role="alert"><p><b>The last read failed.</b> ${why(err)} This is what was read before, ${when(readAt)}. Nothing was changed.</p>${retry}</div>`;
|
||
// Freshness line in the command bar for bus pages.
|
||
const stamp = iso => { const t = Date.parse(iso); return Number.isFinite(t) ? `${new Date(t).toISOString().slice(11, 19)} UTC` : 'unknown'; };
|
||
// The queue, business and settings pages name their own source.
|
||
function freshBus(state, source = 'Bus') {
|
||
$('fresh-bus').innerHTML = state === 'refused' ? `${esc(source)} <span class="warn">not read: refused</span>` : state === 'failed' ? `${esc(source)} <span class="warn">not read: the read failed</span>`
|
||
: `${esc(source)} read <b>${esc(stamp(readAt))}</b>${readAt ? ` (${esc(ago(readAt))})` : ''}${state === 'stale' ? ' · <span class="warn">stale: the last read failed</span>' : ''}`;
|
||
}
|
||
const SOURCES = { queue: 'Queue', business: 'Business', settings: 'Settings' };
|
||
|
||
// ---------------------------------------------------------------- router
|
||
// #/ and anything that is not a #/ route is the control board; the rest is this file's.
|
||
const routeOf = () => location.hash.startsWith('#/') ? location.hash.slice(1) : '/';
|
||
function view(get, path, q) {
|
||
const parts = path.split('/').filter(Boolean).map(p => { try { return decodeURIComponent(p); } catch { return null; } });
|
||
if (parts.includes(null)) return notFound();
|
||
const [a, b, c] = parts, n = parts.length;
|
||
if (a === 'inbox' && n === 1) return inboxView(get);
|
||
if (a === 'inbox' && n === 2) return SAFE.test(b) ? decisionView(get, b) : notFound(`${txt(b)} is not a decision id.`);
|
||
if (a === 'tasks' && n === 1) return tasksView(get);
|
||
if (a === 'tasks' && n === 2) return TASK.test(b) ? taskView(get, b) : notFound(`${txt(b)} is not a task reference.`);
|
||
if (a === 'agents' && n === 1) return agentsView(get);
|
||
if (a === 'queue' && n === 1) return queueView(get, q);
|
||
if (a === 'queue' && n === 2) return /^[1-9]\d{0,5}$/.test(b) ? queueRowView(get, b) : notFound(`${txt(b)} is not a queue row id.`);
|
||
if (a === 'business' && n === 1) return businessView(get);
|
||
if (a === 'settings' && n === 1) return settingsView(get);
|
||
if (a === 'trail' && n === 3 && b === 'task') return TASK.test(c) ? trailView(get, b, c, q) : notFound(`${txt(c)} is not a task reference.`);
|
||
if (a === 'trail' && n === 3 && b === 'decision') return SAFE.test(c) ? trailView(get, b, c, q) : notFound(`${txt(c)} is not a decision id.`);
|
||
return notFound();
|
||
}
|
||
let gen = 0, shown = null, timer;
|
||
const paused = () => $('pause')?.getAttribute('aria-pressed') === 'true';
|
||
function schedule() { clearTimeout(timer); if (!paused()) timer = setTimeout(() => render(false), 10000); }
|
||
// Keep focus, open <details> and the copy status across a refresh of the same page.
|
||
function keep() {
|
||
const a = document.activeElement, view = $('bus-view');
|
||
return {
|
||
href: view.contains(a) ? a.getAttribute('href') : null, copy: view.contains(a) ? a.dataset?.copy : null, retry: view.contains(a) && a.hasAttribute?.('data-retry'), mirror: view.contains(a) ? a.dataset?.mirror : null, h1: view.contains(a) && a.tagName === 'H1',
|
||
open: [...view.querySelectorAll('details[id]')].filter(d => d.open).map(d => d.id), status: $('copy-status')?.textContent || '',
|
||
};
|
||
}
|
||
function restore(k) {
|
||
for (const id of k.open) { const d = $(id); if (d) d.open = true; }
|
||
if ($('copy-status')) $('copy-status').textContent = k.status;
|
||
const el = [...$('bus-view').querySelectorAll('a[href],[data-copy],[data-retry],[data-mirror]')].find(e => k.href ? e.getAttribute('href') === k.href : k.copy ? e.dataset.copy === k.copy : k.mirror ? e.dataset.mirror === k.mirror : k.retry && e.hasAttribute('data-retry'));
|
||
if (el && (k.href || k.copy || k.retry || k.mirror)) el.focus({ preventScroll: true });
|
||
else if (k.h1) $('bus-view').querySelector('h1')?.focus({ preventScroll: true });
|
||
}
|
||
async function render(navigated) {
|
||
// A refresh due while this render reads would replace it: a navigation would lose its heading focus.
|
||
clearTimeout(timer);
|
||
const route = routeOf(), [path, query = ''] = route.split('?'), board = path === '/' || path === '';
|
||
const g = ++gen;
|
||
document.body.classList.toggle('on-bus', !board);
|
||
$('bus-view').hidden = board;
|
||
sections(path);
|
||
if (board) {
|
||
// The board page keeps its own title; the Inbox count still follows the bus.
|
||
if (shown !== null) { shown = null; $('bus-view').innerHTML = ''; document.title = 'Mosaic Console'; if (navigated) $('main').focus(); }
|
||
try { const r = await fetchRead('inbox'); last.set('inbox', r); if (g === gen) { countInbox(r.rows); sections(path); } }
|
||
catch (err) { if (refusal(err)) { forget(); if (g === gen) sections(path); } }
|
||
if (g === gen) schedule();
|
||
return;
|
||
}
|
||
if (navigated || shown !== route) { $('bus-view').innerHTML = loadingView(SOURCES[path.split('/')[1]]); }
|
||
let html, state = 'fresh';
|
||
const live = reader(true);
|
||
try { html = await view(live, path, new URLSearchParams(query)); readAt = live.oldest(); }
|
||
catch (err) {
|
||
const kept = reader(false);
|
||
try { if (refusal(err)) throw err; html = await view(kept, path, new URLSearchParams(query)); readAt = kept.oldest(); html = staleBanner(err) + html; state = 'stale'; }
|
||
catch { html = failedView(err) + (path === '/settings' ? appearance() : ''); state = refusal(err) ? 'refused' : 'failed'; }
|
||
if (refusal(err)) forget();
|
||
}
|
||
if (g !== gen) return;
|
||
freshBus(state, SOURCES[path.split('/')[1]]);
|
||
const k = !navigated && shown === route ? keep() : null;
|
||
$('bus-view').innerHTML = html; shown = route;
|
||
sections(path);
|
||
const h1 = $('bus-view').querySelector('h1');
|
||
document.title = `${h1 ? h1.textContent : 'Console'} · Mosaic Console`;
|
||
if (k) restore(k);
|
||
else if (navigated && h1) { h1.focus(); announce(h1.textContent); }
|
||
schedule();
|
||
}
|
||
|
||
// ---------------------------------------------------------------- events
|
||
// Copy puts the command on the clipboard and does nothing else. If the clipboard is
|
||
// unavailable, the command text is selected so the person can copy it themselves.
|
||
document.addEventListener('click', async e => {
|
||
// The skip link moves focus only. Its #main would otherwise reach the hash router.
|
||
if (e.target.closest('.skip')) { e.preventDefault(); $('main').focus(); return; }
|
||
const b = e.target.closest('#bus-view [data-copy]');
|
||
if (b) {
|
||
const text = b.dataset.copy, out = $('copy-status');
|
||
try { await navigator.clipboard.writeText(text); out.textContent = `Copied: ${text}`; toast(out.textContent); }
|
||
catch {
|
||
const r = document.createRange(); r.selectNodeContents(b.previousElementSibling);
|
||
const s = getSelection(); s.removeAllRanges(); s.addRange(r);
|
||
out.textContent = 'The clipboard is unavailable. The command is selected; press Ctrl+C to copy it.';
|
||
toast(out.textContent, true);
|
||
}
|
||
return;
|
||
}
|
||
if (e.target.closest('#toast [data-dismiss]')) { toast(null); return; }
|
||
const item = e.target.closest('#cmdk-list [data-href]');
|
||
if (item) { pkGo(item.dataset.href); return; }
|
||
if (e.target.closest('#cmdk')) { pkOpen(); return; }
|
||
if (e.target.closest('#bus-view [data-retry]')) render(false);
|
||
});
|
||
|
||
// ---------------------------------------------------------------- toast
|
||
// One line in a polite live region. Success goes after 2.6 s; an error stays until dismissed.
|
||
let toastTimer;
|
||
function toast(text, error = false) {
|
||
const t = $('toast'); clearTimeout(toastTimer);
|
||
if (text === null) { t.className = 'toast'; t.textContent = ''; return; }
|
||
t.className = `toast on${error ? ' err' : ''}`;
|
||
t.innerHTML = `<span>${esc(text)}</span>${error ? '<button type="button" data-dismiss>Dismiss</button>' : ''}`;
|
||
if (!error) toastTimer = setTimeout(() => toast(null), 2600);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- command palette
|
||
// Ctrl+K opens it. It lists the views, and the decisions and tasks from the last inbox and
|
||
// tasks reads (opening it reads both again, GET only). Enter goes; Esc closes and puts focus
|
||
// back where it was. It only navigates.
|
||
const VIEWS = [['#/', 'Board'], ['#/inbox', 'Inbox'], ['#/tasks', 'Tasks'], ['#/agents', 'Agents'], ['#/queue', 'Queue'], ['#/business', 'Business'], ['#/settings', 'Settings']];
|
||
let pkAll = [], pkShown = [], pkAt = 0, pkBack = null;
|
||
function pkItems() {
|
||
const decisions = (last.get('inbox')?.rows || []).map(d => ({ href: `#/inbox/${enc(d.id)}`, label: first(d.question), group: `decision ${short(d.id)}` }));
|
||
const tasks = (last.get('tasks')?.rows || []).filter(r => TASK.test(String(r.task_ref))).map(r => { const t = task(r); return { href: `#/tasks/${enc(t.ref)}`, label: `#${t.id} ${t.title ?? '(no title in the current read)'}`, group: 'task' }; });
|
||
const rows = (last.get('api:queue')?.rows || []).filter(r => Number.isInteger(r.id)).map(r => ({ href: `#/queue/${r.id}`, label: `${r.id} ${r.piece ?? ''}`, group: `queue row, ${r.state}` }));
|
||
pkAll = [...VIEWS.map(([href, label]) => ({ href, label, group: 'view' })), ...decisions, ...tasks, ...rows];
|
||
}
|
||
function pkFill() {
|
||
const q = $('cmdk-input').value.trim().toLowerCase();
|
||
pkShown = pkAll.filter(i => !q || `${i.label} ${i.group}`.toLowerCase().includes(q)).slice(0, 50);
|
||
pkAt = Math.min(pkAt, Math.max(0, pkShown.length - 1));
|
||
$('cmdk-list').innerHTML = pkShown.length ? pkShown.map((i, n) => `<li role="option" id="cmdk-opt-${n}" data-href="${esc(i.href)}" aria-selected="${n === pkAt}"><span>${txt(i.label)}</span><span class="g">${txt(i.group)}</span></li>`).join('')
|
||
: '<li class="pk-none" role="option" aria-disabled="true" aria-selected="false">No view, decision or task matches.</li>';
|
||
if (pkShown.length) { $('cmdk-input').setAttribute('aria-activedescendant', `cmdk-opt-${pkAt}`); $(`cmdk-opt-${pkAt}`)?.scrollIntoView({ block: 'nearest' }); }
|
||
else $('cmdk-input').removeAttribute('aria-activedescendant');
|
||
}
|
||
function pkOpen() {
|
||
const d = $('cmdk-dialog');
|
||
if (d.open) return;
|
||
pkBack = document.activeElement; pkAt = 0; $('cmdk-input').value = '';
|
||
pkItems(); pkFill(); d.showModal(); $('cmdk-input').focus();
|
||
Promise.allSettled(['inbox', 'tasks'].map(async v => last.set(v, await fetchRead(v)))).then(rs => {
|
||
if (rs.some(r => r.status === 'rejected' && refusal(r.reason))) { forget(); sections(routeOf().split('?')[0]); }
|
||
if (d.open) { pkItems(); pkFill(); }
|
||
});
|
||
}
|
||
function pkGo(href) {
|
||
pkBack = null; $('cmdk-dialog').close();
|
||
if (location.hash === href || (href === '#/' && !location.hash.startsWith('#/'))) { render(true); return; }
|
||
location.hash = href;
|
||
}
|
||
$('cmdk-dialog').addEventListener('close', () => { const b = pkBack; pkBack = null; if (b && document.contains(b)) b.focus(); });
|
||
$('cmdk-input').addEventListener('input', () => { pkAt = 0; pkFill(); });
|
||
$('cmdk-input').addEventListener('keydown', e => {
|
||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); if (pkShown.length) { pkAt = (pkAt + (e.key === 'ArrowDown' ? 1 : -1) + pkShown.length) % pkShown.length; pkFill(); } }
|
||
else if (e.key === 'Enter') { e.preventDefault(); if (pkShown[pkAt]) pkGo(pkShown[pkAt].href); }
|
||
});
|
||
document.addEventListener('keydown', e => {
|
||
if ((e.ctrlKey || e.metaKey) && !e.altKey && e.key.toLowerCase() === 'k') { e.preventDefault(); pkOpen(); return; }
|
||
// Up and down move between the task links of a table, as on the board's session table.
|
||
const a = e.target.closest?.('#bus-view table.s1-table td.s1-title a');
|
||
if (!a || (e.key !== 'ArrowDown' && e.key !== 'ArrowUp')) return;
|
||
const links = [...a.closest('table').querySelectorAll('td.s1-title a')], i = links.indexOf(a);
|
||
e.preventDefault(); links[i + (e.key === 'ArrowDown' ? 1 : -1)]?.focus();
|
||
});
|
||
// Settings' appearance selects drive the command bar's, and follow them back.
|
||
document.addEventListener('change', e => {
|
||
const m = e.target.closest?.('#bus-view select[data-mirror]');
|
||
if (m) { const src = $(m.dataset.mirror); src.value = m.value; src.dispatchEvent(new Event('change')); return; }
|
||
if (e.target.id === 'palette' || e.target.id === 'mode') for (const s of document.querySelectorAll(`#bus-view select[data-mirror="${e.target.id}"]`)) s.value = e.target.value;
|
||
});
|
||
window.addEventListener('hashchange', () => render(true));
|
||
$('refresh').addEventListener('click', () => render(false));
|
||
$('pause').addEventListener('click', () => setTimeout(schedule)); // after app.js flips aria-pressed
|
||
render(false).then(() => { if (!$('bus-view').hidden) { const h1 = $('bus-view').querySelector('h1'); if (h1 && location.hash.startsWith('#/')) h1.focus(); } });
|
||
})();
|