Files
state-hub/dashboard/src/inbox.md
tegwick 90c5ea50f7 feat(dashboard): poll optimisation — T4, T5, T6
T4: workstreams.md and dependencies.md now call /state/deps instead of the
    full /state/summary — removes 2 heavy 10-table queries per 60 s cycle.

T5: index.md's 4 independent polling loops (summaryState, sbomSnapState,
    regsState, wsChartState) consolidated into a single pageState generator
    with one Promise.all batch and a shared backoff counter.

T6: config.js gains waitForVisible(ms) — pauses polling entirely while the
    tab is hidden and fires immediately on visibilitychange.  pollDelay()
    simplified (hidden-tab POLL_HIDDEN logic removed).  All 16 polling pages
    migrated from await sleep(pollDelay(...)) to await waitForVisible(pollDelay(...)).

CUST-WP-0039 complete — all 6 tasks done.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 17:58:18 +02:00

6.7 KiB

title
title
Agent Inbox
import {API, apiFetch, pollDelay, waitForVisible} from "./components/config.js";
// Live poll: messages list
const inboxState = (async function*() {
  let failures = 0;
  while (true) {
    let messages = [], ok = false;
    try {
      const resp = await apiFetch("/messages/?limit=100");
      ok = resp.ok;
      if (ok) messages = await resp.json();
    } catch {}
    failures = ok ? 0 : failures + 1;
    yield {messages, ok, ts: new Date()};
    await waitForVisible(pollDelay({ok, failures}));
  }
})();
const messages = inboxState.messages ?? [];
const _ok = inboxState.ok ?? false;
const _ts = inboxState.ts;

const unread   = messages.filter(m => !m.read_at && !m.archived_at);
const read     = messages.filter(m => m.read_at && !m.archived_at);
const archived = messages.filter(m => m.archived_at);

// Group unread by agent for KPI
const agentCounts = {};
for (const m of unread) {
  agentCounts[m.to_agent] = (agentCounts[m.to_agent] ?? 0) + 1;
}
const topAgents = Object.entries(agentCounts).sort((a, b) => b[1] - a[1]).slice(0, 4);

Agent Inbox

import {injectTocTop} from "./components/toc-sidebar.js";
import {withDocHelp}  from "./components/doc-overlay.js";

const _kpiBox = html`<div class="kpi-infobox">
  <div class="kpi-infobox-title">Inbox</div>
  <div class="kpi-row">
    <span class="kpi-row-label">unread</span>
    <div class="kpi-row-right">
      <div class="kpi-row-value" style="color:${unread.length > 0 ? '#d97706' : 'inherit'}">${unread.length}</div>
    </div>
  </div>
  <div class="kpi-row">
    <span class="kpi-row-label">total</span>
    <div class="kpi-row-right">
      <div class="kpi-row-value" style="font-size:1rem">${messages.length}</div>
    </div>
  </div>
  ${topAgents.map(([agent, count]) => html`
  <div class="kpi-row">
    <span class="kpi-row-label">${agent}</span>
    <div class="kpi-row-right">
      <div class="kpi-row-value" style="font-size:0.9rem">${count}</div>
    </div>
  </div>`)}
</div>`;

const _liveEl = html`<div class="live-indicator">
  <span style="color:${_ok ? 'var(--theme-foreground-focus)' : 'red'}">●</span>
  ${_ok
    ? `Live · updated ${_ts?.toLocaleTimeString()}`
    : html`<span style="color:red">Offline — run: <code>make api</code></span>`}
</div>`;

injectTocTop("inbox-kpi-box", _kpiBox);
injectTocTop("live-indicator", _liveEl);

Inter-agent coordination messages. Agents send messages via send_message() MCP tool and read them via get_messages().


Unread

function fmtDate(s) {
  if (!s) return "—";
  const d = new Date(s);
  return d.toLocaleDateString() + " " + d.toLocaleTimeString([], {hour: "2-digit", minute: "2-digit"});
}

function renderMessage(m, showMarkRead = false) {
  const isBroadcast = m.to_agent === "broadcast";
  const borderColor = !m.read_at ? "#d97706" : "#6b7280";

  async function onMarkRead() {
    await fetch(`${API}/messages/${m.id}/read`, {method: "PATCH"});
  }

  async function onArchive() {
    await fetch(`${API}/messages/${m.id}/archive`, {method: "PATCH"});
  }

  return html`<div class="msg-card" style="border-left-color:${borderColor}">
    <div class="msg-header">
      <span class="msg-from">${m.from_agent}</span>
      <span class="msg-arrow">→</span>
      <span class="msg-to ${isBroadcast ? 'msg-broadcast' : ''}">${m.to_agent}</span>
      <span class="msg-time">${fmtDate(m.created_at)}</span>
      <div class="msg-actions">
        ${showMarkRead ? html`<button class="msg-btn msg-btn-read" onclick=${onMarkRead}>Mark read</button>` : ""}
        <button class="msg-btn msg-btn-archive" onclick=${onArchive}>Archive</button>
      </div>
    </div>
    <div class="msg-subject">${m.subject}</div>
    <details class="msg-body-wrap">
      <summary>body</summary>
      <pre class="msg-body">${m.body}</pre>
    </details>
    ${m.thread_id ? html`<div class="msg-thread">thread: ${m.thread_id}</div>` : ""}
  </div>`;
}

if (unread.length === 0) {
  display(html`<p class="dim">No unread messages.</p>`);
} else {
  display(html`<div class="msg-list">${unread.map(m => renderMessage(m, true))}</div>`);
}

Read

if (read.length === 0) {
  display(html`<p class="dim">No read messages.</p>`);
} else {
  display(html`<div class="msg-list">${read.map(m => renderMessage(m, false))}</div>`);
}

Archived

if (archived.length === 0) {
  display(html`<p class="dim">No archived messages.</p>`);
} else {
  display(html`<div class="msg-list">${archived.map(m => renderMessage(m, false))}</div>`);
}
<style> .live-indicator { font-size: 0.8rem; color: gray; position: relative; padding: 0.55rem 1.8rem 0.55rem 0.7rem; margin-bottom: 0.75rem; } .kpi-row { display: flex; justify-content: space-between; align-items: center; gap: 1rem; padding: 0.3rem 0; } .kpi-row + .kpi-row { border-top: 1px solid var(--theme-foreground-faint, #eee); } .kpi-row-label { font-size: 0.8rem; color: var(--theme-foreground-muted, #666); white-space: nowrap; } .kpi-row-right { text-align: right; } .kpi-row-value { font-size: 1.25rem; font-weight: 700; font-variant-numeric: tabular-nums; line-height: 1.1; } .msg-list { display: flex; flex-direction: column; gap: 0.5rem; } .msg-card { border-left: 4px solid #6b7280; border-radius: 0 6px 6px 0; background: var(--theme-background-alt); padding: 0.6rem 0.9rem; } .msg-header { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin-bottom: 0.3rem; font-size: 0.8rem; } .msg-from { font-weight: 700; color: var(--theme-foreground, #222); } .msg-arrow { color: var(--theme-foreground-muted, #999); } .msg-to { font-weight: 600; color: #1e40af; } .msg-broadcast { color: #7c3aed; } .msg-time { color: var(--theme-foreground-muted, #666); margin-left: auto; } .msg-actions { display: flex; gap: 0.3rem; } .msg-btn { padding: 0.12rem 0.5rem; border-radius: 5px; font-size: 0.7rem; font-weight: 600; cursor: pointer; border: 1px solid; } .msg-btn-read { border-color: #d97706; background: #fffbeb; color: #92400e; } .msg-btn-read:hover { background: #fef3c7; } .msg-btn-archive { border-color: #9ca3af; background: #f9fafb; color: #374151; } .msg-btn-archive:hover { background: #f3f4f6; } .msg-subject { font-weight: 600; font-size: 0.95rem; color: var(--theme-foreground, #222); margin-bottom: 0.2rem; } .msg-body-wrap { font-size: 0.8rem; color: var(--theme-foreground-muted, #555); margin-top: 0.2rem; } .msg-body-wrap summary { cursor: pointer; font-style: italic; } .msg-body { white-space: pre-wrap; margin: 0.4rem 0 0; font-family: var(--mono); font-size: 0.8rem; background: var(--theme-background); padding: 0.5rem; border-radius: 4px; } .msg-thread { font-size: 0.7rem; color: var(--theme-foreground-muted, #999); margin-top: 0.2rem; } .dim { color: gray; font-style: italic; } </style>