One cumulative control-board, webui and seat state. The four rows edit the
same files (scan.mjs, page.html, README.md, app.js), so they land together,
each on its own receipt:
- Row 18, Discord connector rows on the board (#1509): R3 approved by
Darkwing and Dewey, Gitea comment 26257, manifest 254403b8. Jason
accepted the visual test.
- Row 22, board attention status (#1503): Filbert approved R1, comment
26248, manifest e40b58ec; restart receipt 26249.
- #1511, task attribution (row 6 code phase): R2 approved by Filbert and
Dewey, manifest d4c96395. docs/TOOLS.md carries the approved --by usage
line (tools-usage.patch 86bcba3c).
- #1512, relaunch activity (row 6 pilot): R1 approved by Darkwing and
Dewey, candidate manifest 47769fad. All seven source files match it.
Row 16, internal development bootstrap (#1510): the seven files outside
shared records match Filbert's R1 pins, receipt 26204 (agents/researcher/*,
scripts/test-darkwing-launch.mjs, the bootstrap plan).
packages/webui/src/public/app.js is committed at its #1512 R1 pin ce7d79a4.
The working copy holds Dewey's unreviewed return-flow candidate on top of
that, and it stays uncommitted.
Also: the four row briefs and Darkwing's evidence records under
agents/darkwing/work, including the 2026-09-26 tree manifest and the #1512
re-run against 21e3e908. Serial acceptance command: 397/397, three runs.
The failures that only show when tests run concurrently are in #1509 engine
tests, and they reproduce on clean HEAD.
Suites on the exact staged tree: config 24, task 90, foundation 43,
conductor 17, release 14, auth 15, discord 63; package union 397/397
(serial); test-darkwing-launch 5/5.
Shared records (BUILD-LOG, QUEUE, CURRENT, DEFERRED, SESSIONS, AGENTS.md,
agents/README.md) follow in Sage's records commit.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
606 lines
30 KiB
HTML
606 lines
30 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Control board</title>
|
|
<style>
|
|
:root{
|
|
--canvas:#f4f6f8;--surface:#fff;--raised:#e9edf1;--text:#16202a;--muted:#5a6774;
|
|
--line:#e2e7ec;--action:#1f5f8b;--onAction:#fff;--accent:#c96a2b;
|
|
--success:#2e7d4f;--warning:#b26a00;--danger:#b3261e;
|
|
--font:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
|
|
--mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
|
|
--radius:8px;--radius-lg:14px;
|
|
}
|
|
@media (prefers-color-scheme: dark){
|
|
:root{
|
|
--canvas:#11161c;--surface:#182029;--raised:#1f2833;--text:#e7edf3;--muted:#94a3b0;
|
|
--line:#2a343f;--action:#6ba6d6;--onAction:#0b1116;--accent:#e08a4c;
|
|
--success:#57b586;--warning:#e0a63c;--danger:#e58077;
|
|
}
|
|
}
|
|
*,*::before,*::after{box-sizing:border-box}
|
|
html{font-family:var(--font);background:var(--canvas);color:var(--text);line-height:1.45}
|
|
body{margin:0 auto;max-width:1100px;padding:16px}
|
|
h1,h2,h3{margin:0 0 .4em;line-height:1.2;font-weight:600}
|
|
h1{font-size:1.5rem}h2{font-size:1.1rem;margin-top:1.4em}h3{font-size:.95rem}
|
|
:focus-visible{outline:3px solid var(--action);outline-offset:2px;border-radius:4px}
|
|
.page-head{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px 16px}
|
|
.head-controls{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
|
.status-line{margin:0;color:var(--muted);font-size:.9rem}
|
|
.btn{font:inherit;font-weight:600;font-size:.85rem;min-height:34px;padding:6px 12px;border-radius:var(--radius);
|
|
border:1px solid var(--line);background:var(--surface);color:var(--text);cursor:pointer}
|
|
.btn:hover{background:var(--raised)}
|
|
.error-banner{margin:12px 0;padding:10px 14px;border-radius:var(--radius);border:1px solid var(--danger);
|
|
background:color-mix(in srgb,var(--danger) 10%,var(--surface));color:var(--danger);font-size:.9rem}
|
|
.empty{color:var(--muted);font-style:italic}
|
|
#seenDetails summary{cursor:pointer;list-style:none;display:flex;align-items:center;gap:8px}
|
|
#seenDetails summary::-webkit-details-marker{display:none}
|
|
#seenDetails summary::before{content:"\25B8";color:var(--muted);font-size:.9em}
|
|
#seenDetails[open] summary::before{content:"\25BE"}
|
|
#seenDetails summary h2{display:inline;margin:0}
|
|
.table-wrap{overflow-x:auto;border:1px solid var(--line);border-radius:var(--radius-lg);background:var(--surface)}
|
|
table{width:100%;border-collapse:collapse;font-size:.88rem}
|
|
th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--line);vertical-align:top}
|
|
th{font-size:.72rem;text-transform:uppercase;letter-spacing:.03em;color:var(--muted);font-weight:600;white-space:nowrap}
|
|
tbody tr:last-child td{border-bottom:none}
|
|
tr.is-offline{opacity:.55}
|
|
.badge{display:inline-flex;align-items:center;gap:5px;font-size:.76rem;font-weight:600;padding:2px 9px;
|
|
border-radius:99px;border:1px solid transparent;white-space:nowrap}
|
|
.badge::before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor}
|
|
.badge-ok{color:var(--success);background:color-mix(in srgb,var(--success) 12%,var(--surface))}
|
|
.badge-warn{color:var(--warning);background:color-mix(in srgb,var(--warning) 12%,var(--surface))}
|
|
.badge-danger{color:var(--danger);background:color-mix(in srgb,var(--danger) 12%,var(--surface))}
|
|
.badge-muted{color:var(--muted);background:var(--raised)}
|
|
.badge-accent{color:var(--accent);background:color-mix(in srgb,var(--accent) 12%,var(--surface))}
|
|
.row-toggle{font:inherit;font-weight:600;background:none;border:0;padding:2px 0;color:var(--action);
|
|
cursor:pointer;text-align:left}
|
|
.row-toggle:hover{text-decoration:underline}
|
|
.seen-toggle{font:inherit;font-size:.76rem;background:none;border:1px solid var(--border);border-radius:6px;
|
|
padding:1px 8px;color:var(--action);cursor:pointer;margin-left:6px;vertical-align:middle}
|
|
.seen-toggle:hover{border-color:var(--action)}
|
|
.seen-tag{font-size:.72rem;color:var(--muted);margin-left:6px;vertical-align:middle}
|
|
.msg-error{color:var(--danger)}
|
|
.msg-text,.msg-error{display:block;max-width:36ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
.relaunch-notice{display:block;max-width:36ch;overflow-wrap:anywhere;white-space:normal}
|
|
.model{display:block;color:var(--muted);font-size:.75em;white-space:nowrap}
|
|
.task-text{display:block;max-width:28ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
.unknown{color:var(--muted);font-style:italic}
|
|
.reply-form{display:flex;gap:.4em;align-items:center;max-width:60ch}
|
|
.reply-form input{flex:1;min-width:12ch;font:inherit;padding:.2em .4em}
|
|
.reply-receipt{display:block;margin-top:.3em;font-size:.85em}
|
|
.reply-ok{color:var(--muted)}
|
|
.source{color:var(--muted);font-size:.75em;margin-left:.35em;white-space:nowrap}
|
|
.set-by{color:var(--muted);font-size:.75em;margin-left:.35em;white-space:nowrap}
|
|
.detail-row td{background:var(--raised)}
|
|
.detail-list{display:grid;grid-template-columns:auto 1fr;gap:4px 14px;margin:0;font-size:.85rem;font-family:var(--mono)}
|
|
.detail-list dt{color:var(--muted);font-family:var(--font)}
|
|
.detail-list dd{margin:0;overflow-wrap:anywhere}
|
|
.project-group{margin-bottom:20px}
|
|
.project-group-head{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px}
|
|
.group-toggles{display:flex;flex-wrap:wrap;gap:14px}
|
|
.project-group-head label{font-size:.85rem;color:var(--muted);display:flex;align-items:center;gap:6px}
|
|
.offline-note{margin:6px 0 0;font-size:.82rem;color:var(--muted)}
|
|
.page-footer{margin-top:24px;padding-top:12px;border-top:1px solid var(--line);color:var(--muted);font-size:.85rem}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header class="page-head">
|
|
<h1>Control board</h1>
|
|
<div class="head-controls">
|
|
<p id="headerStatus" class="status-line" aria-live="polite">Updated never · next refresh in 10s</p>
|
|
<button id="refreshBtn" class="btn" type="button">Refresh</button>
|
|
<button id="pauseBtn" class="btn" type="button" aria-pressed="false">Pause</button>
|
|
</div>
|
|
</header>
|
|
<div id="errorBanner" class="error-banner" role="alert" hidden></div>
|
|
<main id="main">
|
|
<section aria-labelledby="waiting-h">
|
|
<h2 id="waiting-h">Waiting on you</h2>
|
|
<div id="waitingBody"></div>
|
|
</section>
|
|
<section aria-labelledby="seen-h">
|
|
<details id="seenDetails">
|
|
<summary><h2 id="seen-h">Seen</h2></summary>
|
|
<div id="seenBody"></div>
|
|
</details>
|
|
</section>
|
|
<section aria-labelledby="projects-h">
|
|
<h2 id="projects-h">By project</h2>
|
|
<div id="projectsBody"></div>
|
|
</section>
|
|
</main>
|
|
<footer id="footer" class="page-footer"></footer>
|
|
<script>
|
|
(function () {
|
|
"use strict";
|
|
var STATES = ["working", "waiting", "error", "offline", "idle", "unknown"];
|
|
var BADGE_CLASS = { waiting: "badge-warn", error: "badge-danger", working: "badge-ok", offline: "badge-muted", idle: "badge-muted", unknown: "badge-accent" };
|
|
var REFRESH_MS = 10000;
|
|
|
|
var headerStatus = document.getElementById("headerStatus");
|
|
var refreshBtn = document.getElementById("refreshBtn");
|
|
var pauseBtn = document.getElementById("pauseBtn");
|
|
var errorBanner = document.getElementById("errorBanner");
|
|
var waitingBody = document.getElementById("waitingBody");
|
|
var seenBody = document.getElementById("seenBody");
|
|
var seenHeading = document.getElementById("seen-h");
|
|
var projectsBody = document.getElementById("projectsBody");
|
|
var footer = document.getElementById("footer");
|
|
var main = document.getElementById("main");
|
|
|
|
var lastData = null;
|
|
var paused = false;
|
|
var fetching = false;
|
|
var timerId = null;
|
|
var secondsLeft = REFRESH_MS / 1000;
|
|
var hideOfflineState = {};
|
|
var hideSeenState = {};
|
|
// Open detail panels survive a refresh. Keyed by section, project and agent
|
|
// because the same agent can appear in both the waiting list and its group.
|
|
var openDetails = {};
|
|
var rowIdx = 0;
|
|
|
|
function esc(v) {
|
|
if (v === null || v === undefined) return "";
|
|
return String(v).replace(/[&<>"']/g, function (c) {
|
|
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c];
|
|
});
|
|
}
|
|
|
|
function humanAge(s) {
|
|
if (s === null || s === undefined) return "—";
|
|
if (s < 60) return s + "s";
|
|
var m = Math.floor(s / 60);
|
|
if (m < 60) return m + "m";
|
|
var h = Math.floor(m / 60);
|
|
if (h < 24) return h + "h";
|
|
return Math.floor(h / 24) + "d";
|
|
}
|
|
|
|
function timeAgo(iso) {
|
|
if (!iso) return "never";
|
|
var ms = Date.now() - Date.parse(iso);
|
|
if (Number.isNaN(ms)) return "never";
|
|
var s = Math.floor(ms / 1000);
|
|
if (s < 5) return "just now";
|
|
if (s < 60) return s + "s ago";
|
|
var m = Math.floor(s / 60);
|
|
if (m < 60) return m + "m ago";
|
|
var h = Math.floor(m / 60);
|
|
if (h < 24) return h + "h ago";
|
|
return Math.floor(h / 24) + "d ago";
|
|
}
|
|
|
|
function cap(s) { return s.charAt(0).toUpperCase() + s.slice(1); }
|
|
|
|
function badge(state) {
|
|
var cls = BADGE_CLASS[state] || "badge-muted";
|
|
return '<span class="badge ' + cls + '">' + esc(state) + "</span>";
|
|
}
|
|
|
|
// A "Seen" button on rows that need you; a "seen" tag plus "Unsee" on rows
|
|
// you have already marked. The mark clears itself when the agent writes again.
|
|
function seenControl(rec) {
|
|
var needsYou = rec.state === "waiting" || rec.state === "error";
|
|
if (!needsYou || !rec.lastActivity) return "";
|
|
var attrs = ' data-project="' + esc(rec.project) + '" data-agent="' + esc(rec.agent) + '" data-last="' + esc(rec.lastActivity) + '"';
|
|
if (rec.seen) {
|
|
return '<span class="seen-tag">seen</span><button type="button" class="seen-toggle" data-seen="false"' + attrs + ' title="Put this row back under Waiting on you">Unsee</button>';
|
|
}
|
|
return '<button type="button" class="seen-toggle" data-seen="true"' + attrs + ' title="I have read this; hide it from Waiting on you until the agent writes again">Seen</button>';
|
|
}
|
|
|
|
var SOURCE_LABEL = { "registration": "registered", "first-user-message": "first message", "tmux-pane": "tmux pane", "session-cwd": "session cwd", "workspace-git-root": "git root" };
|
|
function sourceLabel(source) { return SOURCE_LABEL[source] || source; }
|
|
function sourceTag(source) { return source ? '<span class="source" title="source: ' + esc(source) + '">' + esc(sourceLabel(source)) + "</span>" : ""; }
|
|
function fromSource(source) { return source ? " (from " + esc(sourceLabel(source)) + ")" : ""; }
|
|
// Who set a registered task (#1511): shown only when the task on the row is
|
|
// the registered one (the scan gives null otherwise). It is what the
|
|
// caller claimed when it ran `mosaic seat task`, not a verified identity,
|
|
// and it never changes what the row lets you do.
|
|
function setByTag(rec) { return rec.taskSetBy ? '<span class="set-by" title="set by (as claimed by the caller, not verified): ' + esc(rec.taskSetBy) + '">set by ' + esc(rec.taskSetBy) + "</span>" : ""; }
|
|
function setByText(rec) { return rec.taskSetBy ? esc(rec.taskSetBy) + " (as claimed by the caller, not verified)" : "—"; }
|
|
// The model a seat is running, e.g. "gpt-6-astra" with the provider in the
|
|
// hover title. modelText() already escapes, so the title takes it as is.
|
|
// Empty when the log has not named one yet.
|
|
function modelTag(rec) { return rec.model ? '<span class="model" title="' + modelText(rec) + '">' + esc(rec.model) + "</span>" : ""; }
|
|
function modelText(rec) { return rec.model ? (rec.provider ? esc(rec.provider) + "/" : "") + esc(rec.model) : "unknown"; }
|
|
// Reply-from-board (#1505): a box and Send only where the board has an
|
|
// address, i.e. a live registration naming a tmux session. Receipts and
|
|
// unsent drafts are kept per row id so a refresh does not lose them.
|
|
var replyReceipts = {};
|
|
var replyDrafts = {};
|
|
function canReply(rec) {
|
|
if (rec.connector) return false;
|
|
var reg = rec.registered;
|
|
return !!(reg && reg.alive !== false && reg.tmux && reg.tmux.session);
|
|
}
|
|
function replyReceipt(id) {
|
|
var r = replyReceipts[id];
|
|
if (!r) return "";
|
|
if (r.delivered) return '<span class="reply-receipt reply-ok">delivered ' + esc(r.sentAt) + " to tmux " + esc(r.session) + "</span>";
|
|
return '<span class="reply-receipt msg-error" style="white-space:normal;max-width:none">failed' + (r.exitCode === null || r.exitCode === undefined ? "" : " (exit " + esc(r.exitCode) + ")") + ": " + esc(r.stderr || r.error || "no output") + "</span>";
|
|
}
|
|
function replyControl(rec) {
|
|
var id = [rec.project, rec.agent].join("/");
|
|
if (rec.connector) return '<span class="unknown">Board replies disabled for Discord connectors</span>';
|
|
if (!canReply(rec)) return '<span class="unknown">reply needs a registered seat</span>' + replyReceipt(id);
|
|
return '<form class="reply-form" data-id="' + esc(id) + '">' +
|
|
'<input type="text" class="reply-text" maxlength="2000" autocomplete="off" aria-label="Reply to ' + esc(rec.agent) + '" placeholder="Reply to ' + esc(rec.agent) + '" value="' + esc(replyDrafts[id] || "") + '">' +
|
|
'<button type="submit">Send</button></form>' + replyReceipt(id);
|
|
}
|
|
function registeredText(reg) {
|
|
if (!reg) return "no (not started through mosaic launch)";
|
|
var parts = ["started " + esc(reg.startedAt || "—")];
|
|
if (reg.updatedAt) parts.push("task updated " + esc(reg.updatedAt));
|
|
if (reg.harness) parts.push("harness " + esc(reg.harness));
|
|
if (reg.pid) parts.push("pid " + esc(reg.pid));
|
|
if (reg.tmux && reg.tmux.session) parts.push("tmux " + esc(reg.tmux.session) + (reg.tmux.socket ? " (socket " + esc(reg.tmux.socket) + ")" : ""));
|
|
if (reg.layout) parts.push(esc(reg.layout) + " layout");
|
|
if (reg.alive === false) parts.push("stale: pid " + esc(reg.pid) + " is gone, so the derived values are shown");
|
|
return parts.join("; ");
|
|
}
|
|
|
|
function relaunchNotice(rec) { return rec.relaunchedAt ? 'relaunched at ' + esc(rec.relaunchedAt) + ', no messages since' : ''; }
|
|
function buildRowPair(rec, showProject) {
|
|
var idx = rowIdx++;
|
|
var key = (showProject ? "waiting:" : "group:") + [rec.project, rec.agent].join("/");
|
|
var open = !!openDetails[key];
|
|
var cls = "session-row" + (rec.state === "offline" ? " is-offline" : "");
|
|
var notice = relaunchNotice(rec);
|
|
var msg = notice ? '<span class="relaunch-notice">' + notice + '</span>' : rec.state === "error" && rec.lastError
|
|
? '<span class="msg-error" title="' + esc(rec.lastError) + '">' + esc(rec.lastError) + "</span>"
|
|
: '<span class="msg-text" title="' + esc(rec.lastAssistantText || "") + '">' + esc(rec.lastAssistantText || "—") + "</span>";
|
|
var projectCell = showProject ? "<td>" + esc(rec.project) + "</td>" : "";
|
|
// Gate A fields: from the seat's registration (mosaic launch), else derived
|
|
// from the log and tmux, else "unknown". Never guessed. The source tag on
|
|
// the row says which one is shown.
|
|
var task = rec.task
|
|
? '<span class="task-text" title="' + esc(rec.task) + '">' + esc(rec.task) + "</span>" + sourceTag(rec.taskSource) + setByTag(rec)
|
|
: '<span class="unknown">unknown</span>';
|
|
var activeProject = rec.activeProject
|
|
? '<span title="' + esc(rec.workspace || "") + '">' + esc(rec.activeProject) + "</span>" + sourceTag(rec.activeProjectSource)
|
|
: '<span class="unknown" title="' + esc(rec.workspace || "") + '">unknown</span>';
|
|
var main =
|
|
'<tr class="' + cls + '">' +
|
|
projectCell +
|
|
'<td><button type="button" class="row-toggle" data-idx="' + idx + '" data-key="' + esc(key) + '" aria-expanded="' + (open ? "true" : "false") + '" aria-controls="detail-' + idx + '">' + esc(rec.agent) + "</button>" + modelTag(rec) + "</td>" +
|
|
"<td>" + badge(rec.state) + (rec.connector ? '<span class="unknown"> · ' + (rec.connector.braked === true ? 'braked (STOP)' : rec.connector.braked === false ? 'not braked' : 'brake unknown') + ' · owner ' + esc(rec.connector.ownerState) + '</span>' : '') + seenControl(rec) + "</td>" +
|
|
"<td>" + (notice ? '<span class="relaunch-notice">' + notice + '</span>' : esc(humanAge(rec.ageSeconds))) + "</td>" +
|
|
"<td>" + task + "</td>" +
|
|
"<td>" + activeProject + "</td>" +
|
|
"<td>" + msg + "</td>" +
|
|
"</tr>";
|
|
var span = showProject ? 7 : 6;
|
|
var tmux = rec.tmux && rec.tmux.session
|
|
? esc(rec.tmux.session) + (rec.tmux.socket ? " (socket " + esc(rec.tmux.socket) + ")" : "")
|
|
: "—";
|
|
var detail =
|
|
'<tr class="detail-row" id="detail-' + idx + '"' + (open ? "" : " hidden") + '><td colspan="' + span + '">' +
|
|
'<dl class="detail-list">' +
|
|
"<dt>Session ID</dt><dd>" + esc(rec.sessionId || "—") + "</dd>" +
|
|
"<dt>Session file</dt><dd>" + esc(rec.sessionFile || "—") + "</dd>" +
|
|
"<dt>Task</dt><dd>" + (rec.task ? esc(rec.task) : "unknown") + fromSource(rec.taskSource) + "</dd>" +
|
|
"<dt>Task set by</dt><dd>" + setByText(rec) + "</dd>" +
|
|
"<dt>Active project</dt><dd>" + (rec.activeProject ? esc(rec.activeProject) : "unknown") + fromSource(rec.activeProjectSource) + "</dd>" +
|
|
"<dt>Workspace</dt><dd>" + (rec.workspace ? esc(rec.workspace) : "unknown") + fromSource(rec.workspaceSource) + "</dd>" +
|
|
"<dt>Model</dt><dd>" + modelText(rec) + "</dd>" +
|
|
"<dt>Registered</dt><dd>" + registeredText(rec.registered) + "</dd>" +
|
|
"<dt>Reply</dt><dd>" + replyControl(rec) + "</dd>" +
|
|
"<dt>Session cwd</dt><dd>" + esc(rec.cwd || "—") + "</dd>" +
|
|
"<dt>Tmux session</dt><dd>" + tmux + "</dd>" +
|
|
(notice ? "<dt>Current activity</dt><dd>" + notice + "</dd>" : "") +
|
|
"<dt>" + (notice ? "Historical last activity" : "Last activity") + "</dt><dd>" + esc(rec.lastActivity || "—") + "</dd>" +
|
|
"<dt>Scanned at</dt><dd>" + esc(rec.scannedAt || "—") + "</dd>" +
|
|
"<dt>Skipped lines</dt><dd>" + esc(rec.skippedLines) + "</dd>" +
|
|
"<dt>" + (notice ? "Historical last message" : "Last message") + "</dt><dd>" + esc(rec.lastAssistantText || "—") + "</dd>" +
|
|
"<dt>" + (notice ? "Historical last error" : "Last error") + "</dt><dd>" + esc(rec.lastError || "—") + "</dd>" +
|
|
"</dl></td></tr>";
|
|
return main + detail;
|
|
}
|
|
|
|
// Rows you marked Seen, in one place, so they can be found and put back.
|
|
// Collapsed by default; the open/closed choice survives a refresh.
|
|
function renderSeen(data) {
|
|
var list = (data.sessions || []).filter(function (r) { return r.seen; });
|
|
list.sort(function (a, b) {
|
|
var aa = a.ageSeconds === null || a.ageSeconds === undefined ? Infinity : a.ageSeconds;
|
|
var bb = b.ageSeconds === null || b.ageSeconds === undefined ? Infinity : b.ageSeconds;
|
|
return aa - bb;
|
|
});
|
|
seenHeading.textContent = "Seen (" + list.length + ")";
|
|
if (list.length === 0) {
|
|
seenBody.innerHTML = '<p class="empty">Nothing is marked seen. Rows you mark come back here, and return to "Waiting on you" on their own when the agent writes again.</p>';
|
|
return;
|
|
}
|
|
var rows = list.map(function (r) { return buildRowPair(r, true); }).join("");
|
|
seenBody.innerHTML =
|
|
'<div class="table-wrap"><table><thead><tr>' +
|
|
"<th scope=\"col\">Project</th><th scope=\"col\">Agent</th><th scope=\"col\">State</th><th scope=\"col\">Age</th><th scope=\"col\">Task</th><th scope=\"col\">Active project</th><th scope=\"col\">Last message</th>" +
|
|
"</tr></thead><tbody>" + rows + "</tbody></table></div>";
|
|
}
|
|
|
|
function renderWaiting(data) {
|
|
var list = (data.sessions || []).filter(function (r) { return r.waitingOnYou; });
|
|
list.sort(function (a, b) {
|
|
var ae = a.state === "error" ? 0 : 1, be = b.state === "error" ? 0 : 1;
|
|
if (ae !== be) return ae - be;
|
|
var aa = a.ageSeconds === null || a.ageSeconds === undefined ? Infinity : a.ageSeconds;
|
|
var bb = b.ageSeconds === null || b.ageSeconds === undefined ? Infinity : b.ageSeconds;
|
|
return aa - bb;
|
|
});
|
|
if (list.length === 0) {
|
|
waitingBody.innerHTML = '<p class="empty">Nothing is waiting on you.</p>';
|
|
return;
|
|
}
|
|
var rows = list.map(function (r) { return buildRowPair(r, true); }).join("");
|
|
waitingBody.innerHTML =
|
|
'<div class="table-wrap"><table><thead><tr>' +
|
|
"<th scope=\"col\">Project</th><th scope=\"col\">Agent</th><th scope=\"col\">State</th><th scope=\"col\">Age</th><th scope=\"col\">Task</th><th scope=\"col\">Active project</th><th scope=\"col\">Last message</th>" +
|
|
"</tr></thead><tbody>" + rows + "</tbody></table></div>";
|
|
}
|
|
|
|
function projectOrder(a, b) {
|
|
if (a === "fleet" && b === "fleet") return 0;
|
|
if (a === "fleet") return 1;
|
|
if (b === "fleet") return -1;
|
|
return a.localeCompare(b);
|
|
}
|
|
|
|
function renderProjects(data) {
|
|
var sessions = data.sessions || [];
|
|
var byProject = {};
|
|
sessions.forEach(function (r) {
|
|
(byProject[r.project] = byProject[r.project] || []).push(r);
|
|
});
|
|
var projects = Object.keys(byProject).sort(projectOrder);
|
|
if (projects.length === 0) {
|
|
projectsBody.innerHTML = '<p class="empty">No agents are configured.</p>';
|
|
return;
|
|
}
|
|
projectsBody.innerHTML = projects.map(function (project) {
|
|
var group = byProject[project].slice().sort(function (a, b) { return a.agent.localeCompare(b.agent); });
|
|
if (!(project in hideOfflineState)) hideOfflineState[project] = true;
|
|
if (!(project in hideSeenState)) hideSeenState[project] = true;
|
|
var hideOffline = hideOfflineState[project];
|
|
var hideSeen = hideSeenState[project];
|
|
var offlineHidden = 0, seenHidden = 0;
|
|
var visible = group.filter(function (r) {
|
|
if (hideOffline && r.state === "offline") { offlineHidden += 1; return false; }
|
|
if (hideSeen && r.seen) { seenHidden += 1; return false; }
|
|
return true;
|
|
});
|
|
var rows = visible.map(function (r) { return buildRowPair(r, false); }).join("");
|
|
var noteParts = [];
|
|
if (offlineHidden > 0) noteParts.push(offlineHidden + " offline hidden");
|
|
if (seenHidden > 0) noteParts.push(seenHidden + " seen hidden");
|
|
var note = noteParts.length > 0 ? '<p class="offline-note">' + noteParts.join(" \u00b7 ") + "</p>" : "";
|
|
var headCount = visible.length < group.length ? visible.length + " of " + group.length : String(group.length);
|
|
return (
|
|
'<div class="project-group">' +
|
|
'<div class="project-group-head"><h3>' + esc(project) + ' <span>(' + headCount + ")</span></h3>" +
|
|
'<div class="group-toggles">' +
|
|
'<label><input type="checkbox" class="hide-offline-toggle" data-project="' + esc(project) + '" ' + (hideOffline ? "checked" : "") + "> Hide offline</label>" +
|
|
'<label><input type="checkbox" class="hide-seen-toggle" data-project="' + esc(project) + '" ' + (hideSeen ? "checked" : "") + "> Hide seen</label>" +
|
|
"</div></div>" +
|
|
'<div class="table-wrap"><table><thead><tr>' +
|
|
"<th scope=\"col\">Agent</th><th scope=\"col\">State</th><th scope=\"col\">Age</th><th scope=\"col\">Task</th><th scope=\"col\">Active project</th><th scope=\"col\">Last message</th>" +
|
|
"</tr></thead><tbody>" + (rows || '<tr><td colspan="6" class="empty">No agents.</td></tr>') + "</tbody></table></div>" +
|
|
note + "</div>"
|
|
);
|
|
}).join("");
|
|
}
|
|
|
|
|
|
function renderFooter(data) {
|
|
var counts = data.counts || {};
|
|
var parts = STATES.map(function (s) { return cap(s) + " " + (counts[s] || 0); }).join(" · ");
|
|
parts += " · Seen " + ((data.seen || []).length);
|
|
footer.innerHTML = "<p>" + esc(parts) + " — generated " + esc(timeAgo(data.generatedAt)) + " (" + esc(data.generatedAt || "") + ")</p>";
|
|
}
|
|
|
|
// A refresh rebuilds the tables. If Jason is typing a reply, put the
|
|
// caret back where it was so the periodic refresh does not steal it.
|
|
function withReplyFocus(render) {
|
|
var active = document.activeElement;
|
|
var focus = active && active.classList && active.classList.contains("reply-text")
|
|
? { id: active.form.dataset.id, pos: active.selectionStart }
|
|
: null;
|
|
render();
|
|
if (!focus) return;
|
|
var forms = main.querySelectorAll('.reply-form[data-id="' + focus.id.replace(/"/g, '\\"') + '"]');
|
|
var input = forms.length ? forms[0].querySelector(".reply-text") : null;
|
|
if (!input) return;
|
|
input.focus();
|
|
try { input.setSelectionRange(focus.pos, focus.pos); } catch (e) {}
|
|
}
|
|
|
|
function renderAll() {
|
|
withReplyFocus(function () {
|
|
rowIdx = 0;
|
|
renderWaiting(lastData);
|
|
renderSeen(lastData);
|
|
renderProjects(lastData);
|
|
renderFooter(lastData);
|
|
});
|
|
}
|
|
|
|
function renderHeaderStatus() {
|
|
var updated = lastData ? timeAgo(lastData.generatedAt) : "never";
|
|
var refresh = paused ? "auto-refresh paused" : "next refresh in " + secondsLeft + "s";
|
|
headerStatus.textContent = "Updated " + updated + " · " + refresh;
|
|
}
|
|
|
|
function showError(msg) {
|
|
errorBanner.hidden = false;
|
|
errorBanner.textContent = "Could not refresh: " + msg + ". Showing the last known data.";
|
|
}
|
|
|
|
function hideError() {
|
|
errorBanner.hidden = true;
|
|
errorBanner.textContent = "";
|
|
}
|
|
|
|
function scheduleNextFetch() {
|
|
clearTimeout(timerId);
|
|
if (paused) return;
|
|
secondsLeft = REFRESH_MS / 1000;
|
|
timerId = setTimeout(runFetch, REFRESH_MS);
|
|
}
|
|
|
|
function runFetch() {
|
|
if (fetching) return;
|
|
fetching = true;
|
|
fetch("/api/board", { cache: "no-store" })
|
|
.then(function (res) {
|
|
return res.json().catch(function () {
|
|
throw new Error("the server sent a response that was not valid JSON");
|
|
}).then(function (body) {
|
|
if (!res.ok) throw new Error((body && body.error) || "HTTP " + res.status);
|
|
return body;
|
|
});
|
|
})
|
|
.then(function (body) {
|
|
lastData = body;
|
|
hideError();
|
|
renderAll();
|
|
})
|
|
.catch(function (err) {
|
|
console.error("control board refresh failed", err);
|
|
showError(err && err.message ? err.message : "unknown error");
|
|
})
|
|
.then(function () {
|
|
fetching = false;
|
|
renderHeaderStatus();
|
|
scheduleNextFetch();
|
|
});
|
|
}
|
|
|
|
function setPaused(p) {
|
|
paused = p;
|
|
pauseBtn.textContent = paused ? "Resume" : "Pause";
|
|
pauseBtn.setAttribute("aria-pressed", String(paused));
|
|
if (paused) clearTimeout(timerId);
|
|
else scheduleNextFetch();
|
|
renderHeaderStatus();
|
|
}
|
|
|
|
function postSeen(btn) {
|
|
if (fetching) return;
|
|
fetching = true;
|
|
btn.disabled = true;
|
|
fetch("/api/seen", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ project: btn.dataset.project, agent: btn.dataset.agent, lastActivity: btn.dataset.last, seen: btn.dataset.seen === "true" })
|
|
})
|
|
.then(function (res) {
|
|
return res.json().catch(function () {
|
|
throw new Error("the server sent a response that was not valid JSON");
|
|
}).then(function (body) {
|
|
if (!res.ok) throw new Error((body && body.error) || "HTTP " + res.status);
|
|
return body;
|
|
});
|
|
})
|
|
.then(function (body) {
|
|
lastData = body;
|
|
hideError();
|
|
renderAll();
|
|
})
|
|
.catch(function (err) {
|
|
console.error("marking seen failed", err);
|
|
showError(err && err.message ? err.message : "unknown error");
|
|
btn.disabled = false;
|
|
})
|
|
.then(function () {
|
|
fetching = false;
|
|
renderHeaderStatus();
|
|
scheduleNextFetch();
|
|
});
|
|
}
|
|
|
|
function postReply(form) {
|
|
var id = form.dataset.id;
|
|
var input = form.querySelector(".reply-text");
|
|
var text = input.value;
|
|
if (!text.trim()) return;
|
|
var sendBtn = form.querySelector("button");
|
|
sendBtn.disabled = true;
|
|
fetch("/api/reply", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ agent: id, text: text })
|
|
})
|
|
.then(function (res) {
|
|
return res.json().catch(function () {
|
|
throw new Error("the server sent a response that was not valid JSON");
|
|
}).then(function (body) {
|
|
if (!res.ok) throw new Error((body && body.error) || "HTTP " + res.status);
|
|
return body;
|
|
});
|
|
})
|
|
.then(function (body) {
|
|
replyReceipts[id] = body;
|
|
if (body.delivered) delete replyDrafts[id];
|
|
renderAll();
|
|
// The seat's log shows the effect; fetch now instead of waiting a tick.
|
|
if (body.delivered && !paused) runFetch();
|
|
})
|
|
.catch(function (err) {
|
|
console.error("reply failed", err);
|
|
replyReceipts[id] = { delivered: false, exitCode: null, error: err && err.message ? err.message : "unknown error" };
|
|
renderAll();
|
|
});
|
|
}
|
|
|
|
main.addEventListener("submit", function (e) {
|
|
var form = e.target.closest(".reply-form");
|
|
if (!form) return;
|
|
e.preventDefault();
|
|
postReply(form);
|
|
});
|
|
|
|
main.addEventListener("input", function (e) {
|
|
var input = e.target.closest(".reply-text");
|
|
if (!input) return;
|
|
replyDrafts[input.form.dataset.id] = input.value;
|
|
});
|
|
|
|
main.addEventListener("click", function (e) {
|
|
var seenBtn = e.target.closest(".seen-toggle");
|
|
if (seenBtn) return postSeen(seenBtn);
|
|
var btn = e.target.closest(".row-toggle");
|
|
if (!btn) return;
|
|
var detail = document.getElementById("detail-" + btn.dataset.idx);
|
|
var expanded = btn.getAttribute("aria-expanded") === "true";
|
|
btn.setAttribute("aria-expanded", String(!expanded));
|
|
detail.hidden = expanded;
|
|
if (expanded) delete openDetails[btn.dataset.key];
|
|
else openDetails[btn.dataset.key] = true;
|
|
});
|
|
|
|
main.addEventListener("change", function (e) {
|
|
var cb = e.target.closest(".hide-offline-toggle, .hide-seen-toggle");
|
|
if (!cb) return;
|
|
var stateMap = cb.classList.contains("hide-seen-toggle") ? hideSeenState : hideOfflineState;
|
|
stateMap[cb.dataset.project] = cb.checked;
|
|
renderProjects(lastData);
|
|
});
|
|
|
|
refreshBtn.addEventListener("click", runFetch);
|
|
pauseBtn.addEventListener("click", function () { setPaused(!paused); });
|
|
|
|
setInterval(function () {
|
|
if (!paused && secondsLeft > 0) secondsLeft -= 1;
|
|
renderHeaderStatus();
|
|
}, 1000);
|
|
|
|
renderHeaderStatus();
|
|
runFetch();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|