Brand board revisions, the five dashboard mockups with their verify
script and evidence, the slice 1 S5 design note (SLICE1-VIEWS.md, rev 3
plus the S2c section 10 update), the Console mockup of inbox, tasks,
agents and trail (mockups/slice1/) and checks/slice1-verify.mjs (16
checks; check 16 pinned to bus at d27042fa). Also the CHAT-03 progress
log and the 2026-09-26 dirty-tree manifest. Design and evidence only;
no package code. Row 40 (#1522), committed on its own at Sage's
direction, 2026-10-09.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
424 lines
40 KiB
JavaScript
424 lines
40 KiB
JavaScript
// Slice 1 S5 mockup: inbox, tasks, agents and trail in the live Console shell.
|
||
// Two layers. `reads` turns the arrays of the Q1 read module (packages/bus views, lead
|
||
// decision 56, Q1; q1.js stands in for it) into view objects, and the renderers below see
|
||
// only those objects. What no Q1 verb returns comes from S1Q1.missing and is labelled where
|
||
// it shows. Nothing on this page writes anywhere.
|
||
(() => {
|
||
'use strict';
|
||
const $ = id => document.getElementById(id);
|
||
const esc = v => String(v ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||
// The CHAT-02 rules from the live app: 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- or tracker-authored string goes through this
|
||
const announce = text => { $('announce').textContent = text; };
|
||
|
||
const FULL = window.S1;
|
||
const EMPTY = { ...FULL, role_claims: [], decisions: [], decision_events: [], messages: [], deliveries: [], task_snapshots: [], events: [] };
|
||
|
||
// ---------------------------------------------------------------- read layer
|
||
// Q is the Q1 module's four reads (q1.js stands in for packages/bus views). M is what no Q1 verb
|
||
// returns yet; every view that shows something from M says so. Nothing here reads a raw table.
|
||
function reads(S) {
|
||
const Q = S1Q1.views(S), M = S1Q1.missing(S), L = M.business, NOW = Date.parse(S.NOW);
|
||
const CLOSE = ['resolved', 'withdrawn', 'expired'];
|
||
const of = (rows, table) => rows.filter(r => r.table === table);
|
||
const byOldest = (a, b) => a.at.localeCompare(b.at);
|
||
// One decision from its trail. An inbox row already carries the authorization context; for any
|
||
// other decision it comes from the trail's decision.raise event, the rule the broker's
|
||
// #decisionView uses. Seen and DM state come from the trail too (one trail read per decision).
|
||
function decision(id, row) {
|
||
const tr = Q.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: 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').map(x => ({ at: x.at, by: x.by, via: x.via })),
|
||
closed: closed && { op: closed.op, at: closed.at, by: closed.by, choice: closed.choice, note: closed.note, via: closed.via },
|
||
dm: dm ? { at: dm.at } : null, supersedes: d.supersedes,
|
||
};
|
||
}
|
||
function inbox() {
|
||
return {
|
||
// The module orders gated first, then oldest; every human-routed decision is gated, so this only lifts blocking ones.
|
||
human: Q.inbox().map(r => decision(r.id, r)).sort((a, b) => (b.blocking - a.blocking) || byOldest(a, b)),
|
||
roles: M.decisions.roles.map(r => decision(r.id, r)).sort(byOldest),
|
||
recent: M.decisions.closed.map(r => decision(r.id, r)).sort((a, b) => b.closed.at.localeCompare(a.closed.at)),
|
||
missingSource: M.decisions.source,
|
||
};
|
||
}
|
||
function chain(id) { // earlier versions through `supersedes`, newest first, one trail read each
|
||
const out = []; let d = decision(id);
|
||
while (d?.supersedes) { d = decision(d.supersedes); if (d) out.push(d); }
|
||
return out;
|
||
}
|
||
|
||
// Tasks: one row per task from task_current (schema v3b), detail from the task's trail.
|
||
function task(row) {
|
||
const ref = row.task_ref, f = row.fields, tr = Q.trail(ref);
|
||
const evs = of(tr, 'events').filter(e => e.subject === ref), snaps = of(tr, 'task_snapshots');
|
||
const created = evs.find(e => e.kind === 'task.created');
|
||
const conflict = evs.filter(e => e.kind === 'task.conflict').map(e => ({ event: e.id, ...e.body }))
|
||
.find(c => !of(tr, 'decision_events').some(x => x.decision === c.decision && CLOSE.includes(x.op))) || null;
|
||
const missingEv = evs.filter(e => e.kind === 'task.missing').at(-1);
|
||
const shape = f.gone ? snaps.filter(s => !s.fields.gone).at(-1)?.fields || {} : f;
|
||
// task_current already skips stale reads, so a current row from a poll is a change the stack didn't make.
|
||
const ext = evs.filter(e => e.kind === 'task.changed.external').at(-1);
|
||
const external = !f.gone && row.source === 'poll' ? { kind: snaps.some(s => s.source === 'self') ? 'changed' : 'foreign', via: row.via, readAt: row.read_at, fields: ext ? ext.body.fields : [], event: ext?.id || null } : null;
|
||
const [, project, id] = ref.match(/^vikunja:(\d+)\/(\d+)$/);
|
||
const bucketOf = x => x.gone ? `gone (${x.gone})` : L.buckets[x.bucket] || `bucket ${x.bucket}`;
|
||
return {
|
||
ref, project: +project, id: +id, index: shape.index ?? +id, title: shape.title ?? null, description: shape.description ?? '',
|
||
bucket: f.gone ? null : bucketOf(f), done: !!f.done,
|
||
assignee: (shape.assignees || []).map(a => L.bots[a] || `user ${a}`), requirement: created?.body.requirement ?? null, request: created?.body.request ?? null,
|
||
due: shape.due_date, priority: shape.priority ?? 0, percent: shape.percent_done ?? 0,
|
||
changedAt: row.at, external, conflict,
|
||
missing: f.gone ? { reason: f.gone, at: row.at, project: missingEv?.body.project ?? null } : null,
|
||
review: evs.filter(e => e.kind.startsWith('review.')).map(e => ({ kind: e.kind, at: e.at, ...e.body })),
|
||
// 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, bucket: bucketOf(s.fields) })),
|
||
url: `${L.tracker.baseUrl}/tasks/${id}`,
|
||
};
|
||
}
|
||
function tasks() {
|
||
const ts = Q.tasks().map(task);
|
||
return {
|
||
freshness: M.status,
|
||
groups: Object.values(L.buckets).map(b => ({ bucket: b, rows: ts.filter(t => t.bucket === b).sort((x, y) => y.changedAt.localeCompare(x.changedAt)) })),
|
||
missing: ts.filter(t => t.missing),
|
||
};
|
||
}
|
||
|
||
// Agents: holders from the module; instances, launch state, sessions and credential notices from M.
|
||
function agents() {
|
||
const claims = Q.agents(), inst = Object.fromEntries(L.instances.map(i => [i.instance, i]));
|
||
const family = role => L.families[inst[role]?.model] || 'unknown';
|
||
const live = M.sessions.live.map(l => ({ ...l, family: family(l.role) }));
|
||
const counts = {}; for (const l of live) counts[l.family] = (counts[l.family] || 0) + 1;
|
||
return {
|
||
limits: Object.entries(L.launch.max).map(([fam, max]) => ({ family: fam, live: counts[fam] || 0, max })),
|
||
launch: M.launch, launchRule: L.launch,
|
||
instances: L.instances.map(i => {
|
||
const c = claims.find(r => r.role === i.instance) || null;
|
||
const creds = {};
|
||
for (const n of M.credentials.notices) if (n.instance === i.instance) creds[n.service] = { kind: n.kind, at: n.at, expires: n.expires || null };
|
||
return {
|
||
...i,
|
||
holder: c && { run: c.holder_run, harness: c.harness, address: c.address, at: c.at, advisory: c.harness === 't3' },
|
||
session: live.find(l => l.role === i.instance) || null,
|
||
credentials: Object.entries(creds).map(([service, v]) => ({ service, ...v })),
|
||
};
|
||
}),
|
||
ended: M.sessions.ended.sort((a, b) => b.at.localeCompare(a.at)),
|
||
};
|
||
}
|
||
|
||
// Trail: the module's rows, oldest first (time, table, seq), each turned into one line with a filter group.
|
||
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 H = L.human, cap = s => s[0].toUpperCase() + s.slice(1);
|
||
function line(r, ctx) {
|
||
const out = { at: r.at, table: r.table, actor: null, by: 'broker', decision: null, note: null };
|
||
if (r.table === 'events') {
|
||
const b = r.body;
|
||
out.kind = r.kind; out.decision = 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}, exit ${b.exit})`,
|
||
'human.input': () => b.kind === 'answer' ? (b.op === 'resolved' ? `${H} chose “${b.choice}” from the CLI` : `${H} ${b.op === 'seen' ? 'marked it seen' : b.op} from the CLI`)
|
||
: b.kind === 'instruction' ? `Request from ${H}, recorded from the CLI` : `Admin command from ${H}, CLI`,
|
||
'task.created': () => `Created #${b.index} for ${b.requirement}`,
|
||
'task.assigned': () => `Assigned to ${b.role}`,
|
||
'task.state': () => `Moved from ${b.from} to ${b.to}`,
|
||
'task.closed': () => `Closed with verdict ${b.verdict}`,
|
||
'task.changed.external': () => b.fields.includes('created') ? 'Created in Vikunja, not by the stack' : `Changed in Vikunja: ${b.fields.join(', ')}`,
|
||
'task.conflict': () => `Conflict on ${b.field}: queue says ${b.queue}, Vikunja says ${b.tracker}`,
|
||
'task.missing': () => `No longer readable in Vikunja (${b.reason}${b.project ? `, now in project ${b.project}` : ''})`,
|
||
'review.requested': () => `Review requested, round ${b.round}`,
|
||
'review.verdict': () => `Review verdict: ${b.verdict} (round ${b.round})`,
|
||
'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}` : ''}${b.class ? ` (${b.class})` : ''}`,
|
||
'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}`,
|
||
'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)';
|
||
} else if (r.table === 'messages') {
|
||
out.kind = `message.${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' ? H : 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}: ${r.question.split('\n')[0]}`;
|
||
} else if (r.table === 'decision_events') {
|
||
out.kind = `decision.${r.op}`; out.decision = r.decision; out.by = r.by === H ? 'human' : 'broker';
|
||
out.text = r.op === 'resolved' ? `${r.by} chose “${r.choice}” via ${r.via}` : r.op === 'seen' ? `${r.by} saw it via ${r.via}` : `${cap(r.op)} by ${r.by}${r.note ? `: ${r.note}` : ''}`;
|
||
} else if (r.table === 'task_snapshots') {
|
||
out.kind = `snapshot.${r.source}`;
|
||
const bucket = r.fields.gone ? `gone (${r.fields.gone})` : L.buckets[r.fields.bucket] || `bucket ${r.fields.bucket}`;
|
||
if (r.source === 'self') out.actor = { role: r.role, run: r.run };
|
||
out.text = r.source === 'self' ? `Written: ${bucket}` : `Read from Vikunja (${r.via} read sent ${r.read_at.slice(11, 19)}): ${bucket}`;
|
||
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}` : ''}`;
|
||
}
|
||
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));
|
||
}
|
||
function trail(kind, ref) {
|
||
if (kind === 'task') {
|
||
const row = Q.tasks().find(r => r.task_ref === ref);
|
||
return row ? lines(Q.trail(ref), row.seq) : null;
|
||
}
|
||
if (!decision(ref)) return null;
|
||
// A decision's trail with its earlier versions: one module read per version, merged in the module's order.
|
||
const rows = new Map();
|
||
for (const id of [ref, ...chain(ref).map(c => c.id)]) for (const r of Q.trail(id)) rows.set(`${r.table}:${r.seq}`, r);
|
||
return lines([...rows.values()].sort((a, b) => a.at.localeCompare(b.at) || a.table.localeCompare(b.table) || a.seq - b.seq));
|
||
}
|
||
return { NOW, lookups: L, inbox, decision: id => decision(id), chain, tasks, task: ref => { const row = Q.tasks().find(r => r.task_ref === ref); return row ? task(row) : null; }, agents, trail };
|
||
}
|
||
|
||
// ---------------------------------------------------------------- renderers
|
||
let state = 'normal', R = reads(FULL), lastRead = FULL.NOW, firstRender = true;
|
||
const enc = encodeURIComponent;
|
||
function ago(iso) {
|
||
const s = Math.max(0, Math.round((R.NOW - Date.parse(iso)) / 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 => `<time datetime="${esc(iso)}" title="${esc(iso)}">${esc(iso.slice(11, 16))}</time>`;
|
||
const short = id => esc(id.slice(0, 8));
|
||
const badge = (text, kind = 'muted') => `<span class="badge badge-${kind}">${esc(text)}</span>`;
|
||
const taskLink = ref => ref ? `<a href="#/tasks/${enc(ref)}">#${esc(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 ? `${esc(a.role)} · ${esc(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="status" role="status">${sub}</p>` : ''}</div></div>`;
|
||
const readNote = () => `Read from the broker ${when(lastRead)}.`;
|
||
// Anything shown from S1Q1.missing carries this label: no Q1 verb returns it yet.
|
||
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.';
|
||
|
||
function sections() {
|
||
const route = location.hash.slice(1).split('?')[0] || '/';
|
||
const ib = R.inbox(), urgent = ib.human.filter(d => d.blocking).length;
|
||
const items = [['/', 'Control board', ''], ['/inbox', 'Inbox', `<span class="count" aria-label="${ib.human.length} open for you">${ib.human.length}</span>${urgent ? ` ${badge(`${urgent} 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('');
|
||
}
|
||
|
||
// Inbox -----------------------------------------------------------------
|
||
function decisionCard(d) {
|
||
const first = d.question.split('\n')[0];
|
||
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)}</a></div>
|
||
<p class="s1-badges">${d.blocking ? badge('blocking', 'attn') : ''}${badge(`${d.class} · ${d.action}`)}${d.dm ? badge('DM sent', 'ok') : ''}${d.seen.length ? badge(`seen ${ago(d.seen.at(-1).at)}`) : ''}</p>
|
||
<p class="small s1-auth">${approves(d.authorization)}</p>
|
||
<p class="small muted">Raised by ${esc(d.raisedBy.role)} ${when(d.at)}${d.routeTo !== 'human' ? ` · for ${esc(d.routeTo)}` : ''}${d.taskRef ? ` · task ${taskLink(d.taskRef)}` : ''}${d.requirementRef ? ` · ${esc(d.requirementRef)}` : ''}</p></li>`;
|
||
}
|
||
function inboxView(q) {
|
||
const ib = R.inbox(), view = ['roles', 'recent'].includes(q.get('view')) ? q.get('view') : 'human';
|
||
const tabs = [['human', 'For you', ib.human.length], ['roles', 'Routed to roles', ib.roles.length], ['recent', 'Recent', ib.recent.length]];
|
||
const nav = `<nav aria-label="Inbox views" class="chips">${tabs.map(([k, l, n]) => `<a class="chip" href="#/inbox${k === 'human' ? '' : `?view=${k}`}"${view === k ? ' aria-current="true"' : ''}>${l} <span class="count">${n}</span></a>`).join('')}</nav>`;
|
||
let body;
|
||
if (view === 'human') body = ib.human.length ? `<ul class="waiting s1-decs">${ib.human.map(decisionCard).join('')}</ul>` : '<p class="state">Nothing is waiting on you. New decisions also arrive as a DM when they block work.</p>';
|
||
else if (view === 'roles') {
|
||
const by = {}; for (const d of ib.roles) (by[d.routeTo] ||= []).push(d);
|
||
body = ib.roles.length ? Object.entries(by).map(([role, ds]) => `<section aria-labelledby="h-r-${esc(role)}"><h2 id="h-r-${esc(role)}">For ${esc(role)} <span class="count">${ds.length}</span></h2><ul class="waiting s1-decs">${ds.map(decisionCard).join('')}</ul></section>`).join('')
|
||
: '<p class="state">No open decisions between roles.</p>';
|
||
body = `<p class="small muted">Cross-role decisions go to the arbiter role. They're shown for context; the arbiter resolves them, not you. ${gap('Decisions routed to roles')}</p>${body}`;
|
||
} else body = `<p class="small muted">${gap('Closed decisions')}</p>` + (ib.recent.length ? `<div class="table-wrap" tabindex="0" role="region" aria-label="Recently closed decisions, scroll for all columns"><table class="s1-table"><thead><tr><th scope="col">Decision</th><th scope="col">Outcome</th><th scope="col">By</th><th scope="col">Closed</th><th scope="col">Open for</th></tr></thead><tbody>${ib.recent.map(d => `<tr><td><a href="#/inbox/${enc(d.id)}">${txt(d.question.split('\n')[0])}</a></td><td>${d.closed.op === 'resolved' ? `chose “${txt(d.closed.choice)}”` : esc(d.closed.op)}</td><td>${esc(d.closed.by)}${d.closed.via ? `<span class="source">via ${esc(d.closed.via)}</span>` : ''}</td><td>${when(d.closed.at)}</td><td>${esc(Math.round((Date.parse(d.closed.at) - Date.parse(d.at)) / 60000))} min</td></tr>`).join('')}</tbody></table></div>`
|
||
: '<p class="state">No decisions closed yet.</p>');
|
||
return `${head('Inbox', `${readNote()} Decisions are resolved in a terminal with <code>mosaic decide</code>; Console only shows them.`)}${nav}${body}`;
|
||
}
|
||
function decisionView(id) {
|
||
const d = R.decision(id);
|
||
if (!d) return notFound(`No decision with id ${esc(id)}.`);
|
||
const status = d.closed ? (d.closed.op === 'resolved' ? `Resolved: ${esc(d.closed.by)} chose “${txt(d.closed.choice)}” via ${esc(d.closed.via)} ${when(d.closed.at)}.` : `${esc(d.closed.op[0].toUpperCase() + d.closed.op.slice(1))} by ${esc(d.closed.by)} ${when(d.closed.at)}${d.closed.note ? `: ${txt(d.closed.note)}` : ''}.`) : `Open since ${when(d.at)}.`;
|
||
const options = `<ol class="s1-opts">${d.options.map(o => {
|
||
const cmd = `mosaic decide ${d.id} ${o.key}`, chosen = d.closed?.choice === o.key, approving = d.authorization?.approvalChoice === o.key;
|
||
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>${d.closed ? '' : `<div class="s1-cmd"><code>${txt(cmd)}</code><button type="button" class="btn" data-copy="${esc(cmd)}" aria-label="Copy the command for option ${esc(o.key)}">Copy</button></div>`}</li>`;
|
||
}).join('')}</ol>`;
|
||
const chain = R.chain(d.id);
|
||
const history = [...d.seen.map(s => `<li>Seen by ${esc(s.by)} via ${esc(s.via)} ${when(s.at)}</li>`), ...(d.closed ? [`<li>${esc(d.closed.op)} by ${esc(d.closed.by)}${d.closed.via ? ` via ${esc(d.closed.via)}` : ''} ${when(d.closed.at)}</li>`] : [])];
|
||
return `${head(`Decision ${short(d.id)}`, status)}
|
||
<section aria-labelledby="h-q" class="panel"><h2 id="h-q">Question</h2><p class="s1-q">${txt(d.question)}</p>
|
||
<dl class="kv"><dt>Class</dt><dd>${esc(d.class)} · ${esc(d.action)}${d.blocking ? ` ${badge('blocking', 'attn')}` : ''}</dd><dt>Approval</dt><dd class="s1-auth">${approves(d.authorization)}</dd><dt>Routed to</dt><dd>${esc(d.routeTo)}</dd><dt>Raised by</dt><dd>${esc(d.raisedBy.role)} <span class="source">${esc(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">${esc(d.taskRef)}</span></dd>` : ''}${d.requirementRef ? `<dt>Requirement</dt><dd>${esc(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>${esc(d.id)}</code></dd></dl></section>
|
||
<section aria-labelledby="h-o"><h2 id="h-o">Options</h2>${d.closed ? '' : `<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>`}${options}<p class="small muted" id="copy-status" role="status"></p></section>
|
||
${chain.length ? `<section aria-labelledby="h-c"><h2 id="h-c">Earlier versions</h2><ul class="s1-plain">${chain.map(c => `<li><a href="#/inbox/${enc(c.id)}">${short(c.id)}</a> ${badge(c.closed ? c.closed.op : 'open')} ${txt(c.question.split('\n')[0])}${c.closed?.note ? `<span class="source">${txt(c.closed.note)}</span>` : ''}</li>`).join('')}</ul></section>` : ''}
|
||
<section aria-labelledby="h-h"><h2 id="h-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 = [];
|
||
if (t.external) out.push(t.external.kind === 'foreign' ? badge('not created by the stack', 'attn') : badge(`changed in Vikunja${t.external.fields.length ? `: ${t.external.fields.join(', ')}` : ''}`, 'attn'));
|
||
if (t.conflict) out.push(`<a href="#/inbox/${enc(t.conflict.decision)}" class="s1-marker">${badge('conflict', 'danger')}</a>`);
|
||
if (t.missing) out.push(badge(`missing: ${t.missing.reason}`, 'danger'));
|
||
return out.join(' ');
|
||
}
|
||
function taskRow(t) {
|
||
return `<tr><td class="s1-title"><a href="#/tasks/${enc(t.ref)}">#${esc(t.index)} ${txt(t.title ?? 'untitled')}</a></td><td>${t.assignee.map(esc).join(', ') || '<span class="muted">none</span>'}</td><td>${esc(t.requirement || '') || '<span class="muted">none</span>'}</td><td>${esc(t.due || '') || '<span class="muted">none</span>'}</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', 'Assignee', 'Requirement', '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(f) {
|
||
if (!f) return 'Vikunja last read: unknown.';
|
||
const last = [f.board, f.cursor].sort().at(-1);
|
||
return `Vikunja project ${esc(f.project)} last read ${when(last)} (board read ${clock(f.board)}, cursor read ${clock(f.cursor)})${f.error ? `; last poll failed: ${txt(f.error)}` : ''}. ${f.proposed ? '<span class="feat">from the S3 status read, not built yet</span>' : ''}`;
|
||
}
|
||
function tasksView() {
|
||
const ts = R.tasks(), any = ts.groups.some(g => g.rows.length) || ts.missing.length;
|
||
if (!any) return `${head('Tasks', freshness(ts.freshness))}<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 groups = ts.groups.map(g => {
|
||
const id = `h-b-${g.bucket}`;
|
||
if (g.bucket === 'done') return `<details class="s1-done"><summary><h2 id="${id}">done <span class="count">${g.rows.length}</span></h2></summary>${g.rows.length ? taskTable(g.rows.slice(0, 20), 'Done tasks') : '<p class="muted">None.</p>'}</details>`;
|
||
return `<section aria-labelledby="${id}"><h2 id="${id}">${esc(g.bucket)} <span class="count">${g.rows.length}</span></h2>${g.rows.length ? taskTable(g.rows, `${g.bucket} tasks`) : '<p class="muted">None.</p>'}</section>`;
|
||
}).join('');
|
||
const missing = ts.missing.length ? `<section aria-labelledby="h-missing"><h2 id="h-missing">No longer readable <span class="count">${ts.missing.length}</span></h2><ul class="s1-plain">${ts.missing.map(t => `<li><a href="#/tasks/${enc(t.ref)}">#${esc(t.index)} ${txt(t.title ?? 'untitled')}</a> ${badge(`missing: ${t.missing.reason}`, 'danger')} <span class="source">since ${clock(t.missing.at)}${t.missing.project ? `, now in project ${esc(t.missing.project)}` : ''}</span></li>`).join('')}</ul></section>` : '';
|
||
return `${head('Tasks', freshness(ts.freshness))}<p class="small muted">Business ${esc(R.lookups.business)}. Edit tasks in Vikunja; Console only reads them. ${gap('Bucket names, bot names and the Vikunja address')}</p>${groups}${missing}`;
|
||
}
|
||
function taskView(ref) {
|
||
const t = R.task(ref);
|
||
if (!t) return notFound(`No task ${esc(ref)} in this business.`);
|
||
return `${head(`#${esc(t.index)} ${txt(t.title ?? 'untitled')}`, `${t.missing ? `Missing from Vikunja (${esc(t.missing.reason)}) since ${when(t.missing.at)}.` : `In ${esc(t.bucket)}, last changed ${when(t.changedAt)}.`} ${markers(t)}`)}
|
||
<p><a class="btn" href="${esc(t.url)}" rel="noreferrer" target="_blank">Open in Vikunja</a></p>
|
||
<section class="panel" aria-labelledby="h-t"><h2 id="h-t">Task</h2><dl class="kv"><dt>Ref</dt><dd><code>${esc(t.ref)}</code></dd><dt>Assignee</dt><dd>${t.assignee.map(esc).join(', ') || 'none'}</dd><dt>Requirement</dt><dd>${esc(t.requirement || 'none')}</dd><dt>Due</dt><dd>${esc(t.due || 'none')}</dd><dt>Priority</dt><dd>${esc(PRIORITY[t.priority] || t.priority)}</dd><dt>Done</dt><dd>${esc(t.percent)}%</dd>${t.review.length ? `<dt>Review</dt><dd>${t.review.map(r => `${r.kind === 'review.verdict' ? `verdict ${esc(r.verdict)}` : 'requested'} (queue row ${esc(r.row)}, round ${esc(r.round)}) ${when(r.at)}`).join('<br>')}</dd>` : ''}${t.conflict ? `<dt>Conflict</dt><dd>${esc(t.conflict.field)}: queue says ${esc(t.conflict.queue)}, Vikunja says ${esc(t.conflict.tracker)}. <a href="#/inbox/${enc(t.conflict.decision)}">Decision for the PM</a></dd>` : ''}</dl>
|
||
${t.description ? `<h3>Description</h3><p class="s1-q">${txt(t.description)}</p>` : ''}
|
||
<details class="s1-snaps"><summary>Snapshots <span class="count">${t.snapshots.length}</span></summary><ol class="s1-plain">${t.snapshots.map(s => `<li>${clock(s.at)} ${esc(s.bucket)} <span class="source">${s.source === 'self' ? `written by ${esc(s.role)}` : `${esc(s.via)} read sent ${esc(s.readAt.slice(11, 19))}`}${s.stale ? '; stale read, not shown' : ''}</span></li>`).join('')}</ol></details></section>
|
||
<section aria-labelledby="h-trail"><h2 id="h-trail">Trail</h2>${trailList(R.trail('task', ref), 'all')}<p><a href="#/trail/task/${enc(ref)}">Trail with filters</a></p></section>`;
|
||
}
|
||
|
||
// Agents ----------------------------------------------------------------
|
||
function agentsView() {
|
||
const a = R.agents();
|
||
const limits = a.limits.map(l => `${l.family[0].toUpperCase() + l.family.slice(1)} ${l.live} of ${l.max}`).join(', ');
|
||
const launch = `${a.launch.state === 'revoked' ? `${badge('launching revoked', 'danger')} since ${when(a.launch.at)}` : `${badge('launching allowed', 'ok')}${a.launch.at ? ` <span class="source">restored from the CLI ${when(a.launch.at)}</span>` : ''}`} ${gap('Launch state')}`;
|
||
const holder = h => !h ? '<span class="muted">not held</span>'
|
||
: `${esc(h.run)}${h.advisory ? ` ${badge('advisory', 'muted')}` : ''}<span class="source">${esc(h.harness)}${h.address ? ` · ${esc(h.address)}` : ''} · held since ${clock(h.at)}</span>`;
|
||
const session = s => s ? `${esc(s.run)}<span class="source">${esc(s.family)} · ${esc(s.harness)} · started ${when(s.at)}</span>` : '<span class="muted">none</span>';
|
||
const creds = cs => cs.length ? cs.map(c => `${esc(c.service)}: ${c.kind === 'expiring' ? badge(`expires ${c.expires}`, 'attn') : c.kind === 'expired' ? badge('expired', 'danger') : badge(`changed ${c.at.slice(0, 10)}`)}`).join('<br>') : '<span class="muted">no notices</span>';
|
||
const rows = a.instances.map(i => `<tr><th scope="row">${esc(i.instance)}${i.arbiterFor ? `<span class="source">arbiter, ${esc(i.arbiterFor)}</span>` : ''}</th><td>${esc(i.harness)}<span class="source">${esc(i.model)}</span></td><td>${holder(i.holder)}</td><td>${session(i.session)}</td><td>${creds(i.credentials)}</td></tr>`).join('');
|
||
return `${head('Agents', `${readNote()} Live sessions: ${esc(limits)}.`)}
|
||
<p class="s1-launch">${launch}</p>
|
||
<section aria-labelledby="h-inst"><h2 id="h-inst">Role instances <span class="count">${a.instances.length}</span></h2><p class="small muted">Holders come from the read module; a role with no current claim shows as not held. A claim from a T3 seat is advisory: the seat keeps its own session and the broker can't stop it. ${esc(a.launchRule.by)} may launch ${a.launchRule.instances.map(esc).join(' and ')}. ${gap('Instances, live sessions and credential notices')}</p>
|
||
<div class="table-wrap" tabindex="0" role="region" aria-label="Role instances, scroll for all columns"><table class="s1-table s1-agents"><thead><tr>${['Instance', 'Harness', 'Holder', 'Live session', 'Credentials'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div>
|
||
<p class="small muted">Credential notices show the service and dates only. Console never receives a token or its path.</p></section>
|
||
<section aria-labelledby="h-ended"><h2 id="h-ended">Ended sessions <span class="count">${a.ended.length}</span></h2><p class="small muted">${gap('Ended sessions')}</p>${a.ended.length ? `<ul class="s1-plain">${a.ended.map(e => `<li>${esc(e.role)} · ${esc(e.run)} ended ${when(e.at)} (${txt(e.reason)}, exit ${esc(e.exit)})</li>`).join('')}</ul>` : '<p class="muted">None today.</p>'}</section>
|
||
<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">${esc(r.kind)}</span><div class="s1-ttext"><p>${txt(r.text)}${r.decision && !r.kind.startsWith('decision.') && !r.kind.startsWith('delivery.') ? ` <a href="#/inbox/${enc(r.decision)}">decision ${short(r.decision)}</a>` : ''}</p>${actor(r.actor, r.by)}${r.note ? `<span class="source">${esc(r.note)}</span>` : ''}</div></li>`).join('')}</ol>`;
|
||
}
|
||
function trailView(kind, ref, q) {
|
||
const rows = R.trail(kind, ref);
|
||
if (!rows) return notFound(`No ${esc(kind)} ${esc(ref)}.`);
|
||
const filter = GROUPS.some(([g]) => g === q.get('kind')) ? q.get('kind') : 'all';
|
||
const base = `#/trail/${kind}/${enc(ref)}`;
|
||
const title = kind === 'task' ? `Trail for task #${esc(ref.split('/')[1])}` : `Trail for decision ${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(title, `${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)}`;
|
||
}
|
||
|
||
// Board, not found, states -----------------------------------------------
|
||
const boardView = () => `${head('Control board', 'Unchanged in slice 1. The live Console board is not repeated in this mockup.')}<p>The new sections are in the left column: <a href="#/inbox">Inbox</a>, <a href="#/tasks">Tasks</a> and <a href="#/agents">Agents</a>.</p>`;
|
||
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 broker.</p>`;
|
||
const refusedView = () => `${head('Broker refused the read')}<div class="state state-error" role="alert"><p><b>The broker refused the read: the bus schema digest doesn't match.</b> Console shows nothing rather than a guess, and it doesn't retry around a refusal.</p><p>Check the broker's log for the reason. Nothing was written.</p><div class="actions"><button type="button" class="btn" data-retry>Read again</button></div></div>`;
|
||
|
||
function render() {
|
||
const raw = location.hash.slice(1) || '/', [path, query = ''] = raw.split('?'), q = new URLSearchParams(query);
|
||
const parts = path.split('/').filter(Boolean).map(decodeURIComponent);
|
||
R = reads(state === 'empty' ? EMPTY : FULL);
|
||
sections();
|
||
let html;
|
||
if (state === 'loading') html = loadingView();
|
||
else if (state === 'refused') html = refusedView();
|
||
else {
|
||
html = !parts.length ? boardView()
|
||
: parts[0] === 'inbox' && parts.length === 1 ? inboxView(q)
|
||
: parts[0] === 'inbox' && parts.length === 2 ? decisionView(parts[1])
|
||
: parts[0] === 'tasks' && parts.length === 1 ? tasksView()
|
||
: parts[0] === 'tasks' && parts.length === 2 ? taskView(parts[1])
|
||
: parts[0] === 'agents' && parts.length === 1 ? agentsView()
|
||
: parts[0] === 'trail' && parts.length === 3 && ['task', 'decision'].includes(parts[1]) ? trailView(parts[1], parts[2], q)
|
||
: notFound();
|
||
if (state === 'error') html = `<div class="state state-error s1-stale" role="alert"><p><b>The last read failed:</b> the broker didn't answer within 5 seconds. This is the data from the read before, ${when(lastRead)}. Nothing was changed.</p><div class="actions"><button type="button" class="btn" data-retry>Read again</button></div></div>${html}`;
|
||
}
|
||
$('main').innerHTML = html;
|
||
$('foot-read').textContent = state === 'refused' ? 'Broker refused the read' : `Fixture read at ${new Date(R.NOW).toISOString().slice(11, 16)} UTC`;
|
||
const h1 = document.querySelector('#main h1');
|
||
document.title = `${h1 ? h1.textContent : 'Console'} · Mosaic Console`;
|
||
if (!firstRender && h1) { h1.focus(); announce(h1.textContent); }
|
||
firstRender = false;
|
||
}
|
||
|
||
// Appearance, as the live Console does it.
|
||
let palette = 'harbor', mode = 'light';
|
||
function theme() {
|
||
const p = Brand.palettes.find(x => x.id === palette) || Brand.palettes[0];
|
||
palette = p.id; if (!['light', 'dim', 'dark'].includes(mode)) mode = 'light';
|
||
for (const [name, value] of Object.entries(Brand.tokens(p, mode))) document.documentElement.style.setProperty('--' + name, value);
|
||
document.documentElement.dataset.palette = palette; document.documentElement.dataset.mode = mode;
|
||
$('palette').value = palette; $('mode').value = mode;
|
||
}
|
||
$('palette').innerHTML = Brand.palettes.map(p => `<option value="${p.id}">${p.name}</option>`).join('');
|
||
const qs = new URLSearchParams(location.search);
|
||
palette = qs.get('palette') || palette; mode = qs.get('mode') || mode; state = qs.get('state') || state;
|
||
$('demo').value = state;
|
||
theme();
|
||
$('palette').onchange = e => { palette = e.target.value; theme(); };
|
||
$('mode').onchange = e => { mode = e.target.value; theme(); };
|
||
$('demo').onchange = e => { state = e.target.value; render(); };
|
||
$('refresh').onclick = () => { if (state !== 'refused') state = state === 'loading' ? 'normal' : state; render(); announce('Read again from the fixture.'); };
|
||
|
||
// 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('[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('[data-retry]')) { state = 'normal'; $('demo').value = state; render(); }
|
||
});
|
||
window.addEventListener('hashchange', () => { if (!location.hash || location.hash.startsWith('#/')) render(); });
|
||
window.S1App = { render, reads: () => R, setState: s => { state = s; $('demo').value = s; render(); } };
|
||
render();
|
||
})();
|