feat(webui,conversation): S5 WebUI views and CHAT-03 follow-ups (row 40, #1522)
Dewey's round 3 candidate, manifest
agents/dewey/work/queue-40/candidate-manifest-r3.sha256 (d0aa0ded,
27 files, checked OK in the canonical tree).
- WebUI inbox, tasks, agents and trail views, read-only over /api/bus.
The README says the bus proof ends at the Console process.
- CHAT-03 seal: the engine command is fixed, the engine environment is
explicit, SEAL_FLAGS has --no-approve, escalating is cleared on throw.
- Terminal input typed after Ctrl-T or Ctrl-O is held. Only the run whose
own parse set held drains it (T1), and #run catches errors per action.
- DEFERRED keeps N2 and moves F2 to done, citing T1.
Reviews: Filbert approve (comment 27011, rev 260), Darkwing approve
(27013, rev 264). Landing gate on 8cad7722 plus the candidate: webui 22,
conversation 161, control-board 124, every scripts/test-*.sh green,
test-task 98/0. Mutant Mr survives; its flows test is the first
follow-up row.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
/* Slice 1 S5 (#1522): inbox, tasks, agents and trail (bus.js), on top of the Console and
|
||||
live styles. Nothing here changes those files. On a bus route (body.on-bus) the board's
|
||||
own parts are hidden and the inspector column is dropped; the board itself is untouched. */
|
||||
#board-tree{display:contents}
|
||||
body.on-bus #board-tree,body.on-bus #board-head,body.on-bus #error,body.on-bus #conversation,body.on-bus #board-view,body.on-bus #inspector{display:none}
|
||||
body.on-bus .frame,body.on-bus.has-inspector .frame{grid-template-columns:240px minmax(0,1fr)}
|
||||
@media (min-width:1900px){body.on-bus .frame,body.on-bus.has-inspector .frame{grid-template-columns:280px minmax(0,1fr)}}
|
||||
@media (min-width:2560px){body.on-bus .frame,body.on-bus.has-inspector .frame{grid-template-columns:320px minmax(0,1fr)}}
|
||||
@media (max-width:959px){body.on-bus .frame,body.on-bus.has-inspector .frame{grid-template-columns:minmax(0,1fr)}}
|
||||
.s1-sections .count{margin-left:auto}.s1-auth{overflow-wrap:anywhere}
|
||||
.s1-sections{display:grid;gap:4px;list-style:none;margin:0;padding:0}.s1-section{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:7px 8px;border-radius:6px;color:var(--text);text-decoration:none;font-weight:600;border:1px solid transparent;min-height:38px}
|
||||
.s1-section:hover{background:var(--raised)}.s1-section[aria-current=page]{background:var(--raised);border-color:var(--border);color:var(--action)}
|
||||
/* The h1 takes focus after navigation so screen readers land on the new page; it is not a control, so no ring. */
|
||||
.content h1{overflow-wrap:anywhere}.content h1:focus{outline:none}
|
||||
.page-head>div{min-width:0}#bus-status{overflow-wrap:anywhere}
|
||||
/* Inbox */
|
||||
.s1-decs{grid-template-columns:repeat(auto-fit,minmax(min(100%,340px),1fr));margin-bottom:16px}
|
||||
.s1-dec:not(.is-attn){border-left-color:var(--border);background:var(--surface)}
|
||||
.s1-dec-open{font-weight:600;color:var(--action);overflow-wrap:anywhere;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
|
||||
.s1-badges{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0 4px}.s1-dec .small{margin:4px 0 0;overflow-wrap:anywhere}
|
||||
.s1-q{white-space:pre-wrap;overflow-wrap:anywhere;margin:0 0 12px;max-width:80ch}
|
||||
.s1-opts{display:grid;gap:10px;padding-left:0;list-style:none;margin:0 0 8px}.s1-opt{border:1px solid var(--line);border-radius:var(--r);padding:10px 12px;background:var(--surface)}
|
||||
.s1-opt p{margin:0;overflow-wrap:anywhere}.s1-key{font-family:var(--mono);margin-right:6px}
|
||||
.s1-cmd{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:8px}.s1-cmd code{flex:1 1 260px;min-width:0;overflow-wrap:anywhere;font-size:.82rem;padding:6px 8px;border:1px solid var(--line);border-radius:6px;background:var(--canvas);user-select:all}
|
||||
.s1-plain{list-style:none;padding:0;margin:0 0 10px;display:grid;gap:6px}.s1-plain li{overflow-wrap:anywhere}
|
||||
.chips .chip{gap:6px}.panel{margin-bottom:16px}.panel h3{font-size:1rem;margin:14px 0 6px}
|
||||
/* Tables */
|
||||
table.s1-table td,table.s1-table th{overflow-wrap:anywhere}table.s1-tasks{min-width:900px}table.s1-tasks .s1-title{min-width:220px;max-width:340px}table.s1-agents{min-width:820px}
|
||||
table.s1-table tbody th{font-weight:600;text-transform:none;letter-spacing:0;color:var(--text);font-size:.9rem;white-space:normal}
|
||||
.s1-done summary{list-style:none}.s1-done summary h2{display:inline}.s1-done summary::before{content:'▸ ';color:var(--muted)}.s1-done[open] summary::before{content:'▾ '}.s1-done{margin-bottom:20px}
|
||||
.s1-marker{text-decoration:none}.s1-launch{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
||||
.s1-stale{margin-bottom:16px}.s1-stale p{margin:0}
|
||||
/* Trail */
|
||||
.s1-trail{list-style:none;margin:0 0 12px;padding:0;border-left:2px solid var(--line)}
|
||||
.s1-trow{display:grid;grid-template-columns:4.2em minmax(9em,13em) minmax(0,1fr);gap:4px 12px;padding:8px 0 8px 12px;border-bottom:1px solid var(--line);position:relative}
|
||||
.s1-trow::before{content:'';position:absolute;left:-6px;top:14px;width:10px;height:10px;border-radius:50%;background:var(--muted)}
|
||||
.s1-g-request::before{background:var(--action)}.s1-g-decision::before{background:var(--warning)}.s1-g-launch::before{background:var(--accent)}.s1-g-review::before{background:var(--success)}
|
||||
.s1-twhen{color:var(--muted);font-variant-numeric:tabular-nums}.s1-tkind{font-family:var(--mono);font-size:.8rem;overflow-wrap:anywhere}.s1-ttext{min-width:0}.s1-ttext p{margin:0;overflow-wrap:anywhere;white-space:pre-wrap}
|
||||
@media(max-width:699px){.s1-trow{grid-template-columns:3.6em minmax(0,1fr)}.s1-ttext{grid-column:1/-1}}
|
||||
@media(max-width:959px){.s1-sections{display:flex;flex-wrap:wrap;flex-basis:100%;gap:6px}.s1-section{border-color:var(--border);border-radius:99px;padding:5px 12px}}
|
||||
@media (forced-colors:active){.s1-section[aria-current=page]{outline:2px solid Highlight}.s1-trow::before{background:CanvasText}}
|
||||
@@ -0,0 +1,456 @@
|
||||
// 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 };
|
||||
}
|
||||
// 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.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 : [],
|
||||
requirement: created?.body?.requirement ?? null, request: created?.body?.request ?? null,
|
||||
due: shape.due_date ?? null, priority: shape.priority ?? 0, 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>`;
|
||||
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 = [['/', 'Control board', ''], ['/inbox', 'Inbox', inboxCount ? `<span class="count" aria-label="${inboxCount.open} open for you">${inboxCount.open}</span>${inboxCount.blocking ? ` ${badge(`${inboxCount.blocking} blocking`, 'attn')}` : ''}` : ''], ['/tasks', 'Tasks', ''], ['/agents', 'Agents', '']];
|
||||
$('sections').innerHTML = items.map(([h, l, extra]) => {
|
||||
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"' : ''}>${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 ? badge('blocking', 'attn') : ''}${badge(`${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>` : '<p class="state">Nothing is waiting on you.</p>';
|
||||
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>${txt(d.class)} · ${txt(d.action)}${d.blocking ? ` ${badge('blocking', 'attn')}` : ''}</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" role="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)}`;
|
||||
function taskRow(t) {
|
||||
return `<tr><td class="s1-title"><a href="#/tasks/${enc(t.ref)}">${title(t)}</a></td><td>${t.assignees.map(user).join(', ') || '<span class="muted">none</span>'}</td><td>${due(t.due)}</td><td>${esc(PRIORITY[t.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', '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))}<p class="state">No tasks in this business yet. Tasks appear after the PM creates them or the poller reads them from Vikunja.</p>`;
|
||||
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 || '<p class="state">No open tasks.</p>'}${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>${esc(PRIORITY[t.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
|
||||
async function agentsView(get) {
|
||||
const claims = await get('agents');
|
||||
const rows = claims.map(c => `<tr><th scope="row">${txt(c.role)}</th><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.</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', 'Holder', 'Harness', 'Held since'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div>` : '<p class="state">No role is held right now.</p>'}</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>`;
|
||||
}
|
||||
|
||||
// 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 '<p class="muted">Nothing of this kind in the trail.</p>';
|
||||
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')}<p class="state">${why || 'No page at this address.'} <a href="#/inbox">Go to the inbox</a></p>`; }
|
||||
const loadingView = () => `${head('Reading…')}<p class="state" aria-busy="true"><span class="spinner" aria-hidden="true"></span>Reading from the bus.</p>`;
|
||||
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>`;
|
||||
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.status === 403 ? 'Bus refused the read' : ['not-configured', 'no-bus-host'].includes(err.code) ? 'No bus to read' : 'The read failed';
|
||||
const failedView = err => `${head(failTitle(err))}<div class="state state-error" role="alert"><p><b>${why(err)}</b> Console shows nothing rather than a guess. Nothing was written.</p>${retry}</div>`;
|
||||
const staleBanner = err => `<div class="state state-error 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>`;
|
||||
|
||||
// ---------------------------------------------------------------- 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 === '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'),
|
||||
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]')].find(e => k.href ? e.getAttribute('href') === k.href : k.copy ? e.dataset.copy === k.copy : k.retry && e.hasAttribute('data-retry'));
|
||||
if (el && (k.href || k.copy || k.retry)) el.focus({ preventScroll: true });
|
||||
}
|
||||
async function render(navigated) {
|
||||
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 {}
|
||||
if (g === gen) schedule();
|
||||
return;
|
||||
}
|
||||
if (navigated || shown !== route) { $('bus-view').innerHTML = loadingView(); }
|
||||
let html;
|
||||
const live = reader(true);
|
||||
try { html = await view(live, path, new URLSearchParams(query)); readAt = live.oldest(); }
|
||||
catch (err) {
|
||||
const kept = reader(false);
|
||||
try { html = await view(kept, path, new URLSearchParams(query)); readAt = kept.oldest(); html = staleBanner(err) + html; }
|
||||
catch { html = failedView(err); }
|
||||
}
|
||||
if (g !== gen) return;
|
||||
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}`; }
|
||||
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.';
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.target.closest('#bus-view [data-retry]')) render(false);
|
||||
});
|
||||
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(); } });
|
||||
})();
|
||||
@@ -1,15 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-design="console" data-palette="harbor" data-mode="light">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Mosaic Console</title>
|
||||
<link rel="stylesheet" href="/shared/app.css"><link rel="stylesheet" href="/console.css"><link rel="stylesheet" href="/live.css">
|
||||
<script src="/brand.js" defer></script><script src="/app.js" defer></script></head>
|
||||
<link rel="stylesheet" href="/shared/app.css"><link rel="stylesheet" href="/console.css"><link rel="stylesheet" href="/live.css"><link rel="stylesheet" href="/bus.css">
|
||||
<script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/bus.js" defer></script></head>
|
||||
<body>
|
||||
<a class="skip" href="#main">Skip to content</a>
|
||||
<header class="cmdbar"><a href="/" class="wordmark"><i>M</i>Mosaic<small>Console</small></a>
|
||||
<div class="cmd-right"><label>Palette<select id="palette"></select></label><label>Appearance<select id="mode"><option>light</option><option>dim</option><option>dark</option></select></label><button class="btn" id="refresh" type="button">Refresh</button><button class="btn" id="pause" type="button" aria-pressed="false">Pause</button></div></header>
|
||||
<div class="frame">
|
||||
<nav class="tree" aria-label="Projects"><h2 class="tree-title">Projects</h2><div id="projects"><p class="muted">Loading projects…</p></div><p class="small muted">Select a project to filter sessions. Waiting on you always shows all projects.</p></nav>
|
||||
<main id="main" class="content" tabindex="-1"><div class="page-head"><div><h1>Control board</h1><p id="status" class="small muted" role="status">Loading board…</p></div></div>
|
||||
<nav class="tree" aria-label="Console"><h2 class="tree-title">Console</h2><ul class="s1-sections" id="sections"></ul><div id="board-tree"><h2 class="tree-title">Projects</h2><div id="projects"><p class="muted">Loading projects…</p></div><p class="small muted">Select a project to filter sessions. Waiting on you always shows all projects.</p></div></nav>
|
||||
<main id="main" class="content" tabindex="-1"><div class="page-head" id="board-head"><div><h1>Control board</h1><p id="status" class="small muted" role="status">Loading board…</p></div></div>
|
||||
<div id="error" class="state state-error" role="alert" hidden></div>
|
||||
<section id="conversation" class="conversation" aria-labelledby="conv-title" hidden><div class="page-head"><div><h2 id="conv-title" tabindex="-1">Conversation</h2><p id="conv-meta" class="small muted"></p></div><div class="conv-actions"><label>Session<select id="conv-pick"></select></label><button class="btn" id="conv-back" type="button">Back to board</button></div></div>
|
||||
<div id="conv-markers"></div><p id="conv-status" class="small muted" role="status"></p><ol id="conv-log" class="turns conv-log" aria-label="Conversation history"></ol>
|
||||
@@ -17,6 +17,7 @@
|
||||
<div id="board-view"><section class="console-waiting" aria-labelledby="waiting-title"><h2 id="waiting-title">Waiting on you <span id="waiting-count" class="count">0</span></h2><div id="waiting"><p class="muted">Loading sessions…</p></div></section>
|
||||
<details id="seen-section"><summary>Seen <span id="seen-count" class="count">0</span></summary><div id="seen"></div></details>
|
||||
<section aria-labelledby="sessions-title"><div class="page-head"><h2 id="sessions-title">All sessions <span id="session-count" class="count">0</span></h2><div class="filters"><label><input id="hide-offline" type="checkbox" checked>Hide offline</label><label><input id="hide-seen" type="checkbox" checked>Hide seen</label></div></div><p class="small muted">Select an agent to inspect. Arrow keys move between agents; Enter opens the inspector. Scroll the table for all columns.</p><div id="sessions"></div></section></div>
|
||||
<div id="bus-view" hidden></div>
|
||||
</main>
|
||||
<aside class="inspector" id="inspector" aria-labelledby="inspector-title" hidden><div class="inspector-head"><h2 id="inspector-title" tabindex="-1">Inspector</h2><button type="button" class="btn" id="close">Close</button></div><div id="inspection"></div></aside>
|
||||
</div><footer class="foot"><span id="footer">Mosaic Stack</span><span id="board-url"></span></footer><div id="announce" class="sr-only" aria-live="polite"></div>
|
||||
|
||||
Reference in New Issue
Block a user