Check pi liveness per tmux pane and add "Seen" marks to the control board (#1503)
First step-3 refinement from Jason's daily use. Liveness now lists the panes of the agent's tmux session and counts it alive only if a pane runs pi, so killed pi sessions whose tmux session still exists show offline instead of waiting. A "Seen" button on waiting and error rows stores the row's lastActivity in <dataRoot>/board/seen.json (clicks only, never rewritten by a scan, fail closed if corrupt) and drops the row from "Waiting on you" until the agent writes anything newer; "Unsee" reverses it. New POST /api/seen route: JSON only, 4 KB limit, 400 on bad input. Tests: control-board 63/63 (30 new), registry 69/69. Review APPROVED; receipt docs/plans/reviews/2026-09-12_control-board-step3-seen-marks.md. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
@@ -52,6 +52,10 @@
|
||||
.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}
|
||||
.detail-row td{background:var(--raised)}
|
||||
@@ -151,6 +155,18 @@
|
||||
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>';
|
||||
}
|
||||
|
||||
function buildRowPair(rec, showProject) {
|
||||
var idx = rowIdx++;
|
||||
var key = (showProject ? "waiting:" : "group:") + [rec.project, rec.agent].join("/");
|
||||
@@ -164,7 +180,7 @@
|
||||
'<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></td>" +
|
||||
"<td>" + badge(rec.state) + "</td>" +
|
||||
"<td>" + badge(rec.state) + seenControl(rec) + "</td>" +
|
||||
"<td>" + esc(humanAge(rec.ageSeconds)) + "</td>" +
|
||||
"<td>" + msg + "</td>" +
|
||||
"</tr>";
|
||||
@@ -250,6 +266,7 @@
|
||||
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>";
|
||||
}
|
||||
|
||||
@@ -320,7 +337,43 @@
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user