// refresh.js — Main refresh loop and initialization extracted from dashboard.html

// Sidebar activity indicators — detect changes between refreshes
// Registry: add one line per page. Counter returns a value; badge shows when value increases.
// Sidebar activity-dot signatures. Each function returns a STRING (sig
// computed from the page's underlying state) OR `null` when the cache
// isn't populated yet — null suppresses the comparison so a slice going
// from undefined → populated does NOT flip the dot on the first tick
// after a fetch resolves. Once a signature is computable, it's compared
// against the previously-stored one; any change fires the dot.
//
// All migrated slices read from window._lastX globals set inside async
// /api/<x> .then handlers (issue #2949). Returning null while those
// globals are still undefined prevents the timing race where _detectPage-
// Changes runs synchronously at the end of _processStatusUpdate before
// the .then handlers have populated the caches.
//
// dispatch.completed is capped at 20 server-side, so length-based counters
// peg once the cap is hit; we fingerprint the LAST completed entry's id +
// completed_at instead so a new completion rotates the signature whether
// the cap is reached or not.
const _pageCounters = {
  home: function(d) {
    // dispatch is on /api/dispatch — wait for the cache.
    const dispatch = window._lastDispatch;
    if (!dispatch) return null;
    const completed = dispatch.completed || [];
    const last = completed[completed.length - 1];
    return completed.length + '|' + (last?.id || '') + '|' + (last?.completed_at || '');
  },
  work: function(d) {
    if (!Array.isArray(window._lastWorkItems)) return null;
    const wis = window._lastWorkItems;
    return wis.length + '|' + wis.filter(function(w) { return w.status === 'pending' || w.status === 'dispatched'; }).length;
  },
  plans: function(d) {
    // /api/plans → window._lastPlans (refreshPlans()); /api/prd →
    // window._lastPrdProgress. Both must be populated before we sig.
    if (!Array.isArray(window._lastPlans) || window._lastPrdProgress === undefined) return null;
    const prog = window._lastPrdProgress || {};
    const plans = window._lastPlans;
    return (prog.complete || 0) + '|' + plans.length + '|' + plans.map(function(p) { return (p.file || '') + ':' + (p.status || ''); }).join(',');
  },
  prs: function(d) {
    if (!Array.isArray(window._lastPullRequests)) return null;
    const prs = window._lastPullRequests;
    const counts = {};
    for (const p of prs) counts[p.status || ''] = (counts[p.status || ''] || 0) + 1;
    return prs.length + '|' + Object.keys(counts).sort().map(function(k) { return k + ':' + counts[k]; }).join(',');
  },
  inbox: function(d) {
    // Wait for BOTH inbox and notes to populate — either alone gives a
    // half-stale sig on first arrival.
    if (!Array.isArray(window._lastInbox) || window._lastNotes === undefined) return null;
    const ib = window._lastInbox;
    const notes = window._lastNotes || {};
    return ib.length + '|' + (notes.content || '').length;
  },
  watches: function(d) {
    if (!Array.isArray(window._lastWatches)) return null;
    const ws = window._lastWatches;
    return ws.length + '|' + ws.reduce(function(m, w) { return Math.max(m, new Date(w.last_triggered || 0).getTime() || 0); }, 0);
  },
  meetings: function(d) {
    // Meetings total comes from /api/meetings-total; list from /state/meetings.
    if (window._lastMeetingsTotal === undefined) return null;
    const total = window._lastMeetingsTotal ?? 0;
    const list = window._lastMeetings || [];
    return total + '|' + list.reduce(function(s, m) { return s + (m.round || 0); }, 0);
  },
  pipelines: function(d) {
    if (!Array.isArray(window._lastPipelines)) return null;
    const pls = window._lastPipelines;
    return pls.length + '|' + pls.reduce(function(s, p) { return s + (p.runs || []).length; }, 0);
  },
  schedule: function(d) {
    if (!Array.isArray(window._lastSchedules)) return null;
    const sch = window._lastSchedules;
    return sch.length + '|' + sch.map(function(s) { return (s.id || '') + ':' + (s.enabled === false ? '0' : '1') + ':' + (s.cron || ''); }).sort().join(',');
  },
  tools: function(d) {
    // skills + mcpServers are on /api/status directly, no cache to wait for.
    return (d.skills || []).length + '|' + (d.mcpServers || []).length;
  },
  engine: function(d) {
    const dispatch = window._lastDispatch;
    if (!dispatch) return null;
    const errors = (dispatch.completed || []).filter(function(c) { return c.result === 'error'; });
    const lastErr = errors[errors.length - 1];
    return errors.length + '|' + (lastErr?.id || '') + '|' + (lastErr?.completed_at || '');
  },
  qa: function(d) {
    if (!window._lastQaRunsSummary) return null;
    const q = window._lastQaRunsSummary;
    return (q.total || 0) + '|' + (q.sig || '');
  },
};
let _prevCounts = {};
function _detectPageChanges(data) {
  var changes = {};
  for (var page in _pageCounters) {
    var sig = _pageCounters[page](data);
    if (sig === null) continue;  // cache not ready yet — skip both compare and record
    var sigStr = String(sig);
    if (_prevCounts[page] !== undefined && sigStr !== _prevCounts[page]) changes[page] = true;
    _prevCounts[page] = sigStr;
  }
  return changes;
}

// Change detection — skip renders for sections that haven't changed since last refresh.
//
// Two-layer cache:
//   1. `_lastValueByKey` is an O(1) reference-equality precheck: when the server
//      returns 304 Not Modified (refresh.js ~173) we reuse the same `_lastStatusData`
//      reference tick after tick, so every `_changed(key, data.<slice>)` call below
//      would otherwise re-stringify multi-MB slices for nothing. The JSON.stringify
//      path stays as the fallback for the "fresh object, equal contents" case
//      (S3, W-mpgcikfg000g8dc7).
//   2. RENDER_VERSIONS is the in-process cache-bust knob (R3, W-mpgb0xgc000hf1d3).
//      _changed() caches JSON.stringify(value) per (key, version), so when the
//      input data is byte-identical between ticks the render is skipped. That's
//      the right call 99% of the time, but it has a sharp edge: when a renderer
//      body itself is edited (hot-reloaded via dashboard-build.js or a freshly
//      shipped bundle) and the input stays the same, the stale render persists
//      forever because the cache key still matches. F8 / "projects chip" class
//      of bug.
//
// Bump the matching entry below whenever a renderer's *output* may change
// for the same input. Cross-restart safety lives in the dashboardBuildId
// reload path below — RENDER_VERSIONS handles the within-process case.
const RENDER_VERSIONS = {
  agents: 2,
  prdProgress: 1,
  prdPrs: 1,
  inbox: 2,
  projects: 1,
  notes: 1,
  prd: 1,
  prs: 3,
  archivedPrds: 1,
  engine: 4,
  version: 1,
  adoThrottle: 1,
  ghThrottle: 1,
  dispatch: 2,
  engineLog: 2,
  metrics: 1,
  workItems: 3,
  skills: 1,
  mcpServers: 1,
  schedules: 1,
  watches: 2,
  meetings: 1,
  pipelines: 1,
  pinned: 1,
  kbPayload: 2,
  // settings is a modal-driven section, not a tick-driven render — but the
  // cache-bust entry exists so future tick-driven code (or feature-flag-aware
  // renderers) can call `_changed('settings', …)` and bump on visual revamps.
  // Bumped to 2 by W-mpmwxkcn000646cc (left-rail tabbed Settings layout).
  settings: 2,
};
const _sectionCache = {};
const _lastValueByKey = {};
const _sectionCacheVersions = {};
// Refresh diagnostics: when refresh() opens a tick, it sets _lastChangedFlags
// to a fresh object so every _changed() call publishes its boolean result
// here. After _processStatusUpdate() returns, refresh() captures the map
// into the ring-buffer entry then resets _lastChangedFlags to null so steady-
// state has no side effect. See "Refresh diagnostics" block below.
let _lastChangedFlags = null;

// Per-renderer isolation: a bare `renderX(...)` call that throws used to
// abort the rest of _processStatusUpdate, leaving the work-items and
// dispatch tables frozen until a hard refresh. _safeRender wraps each
// call so one throw can't take out the chain — every downstream renderer
// still runs and paints fresh DOM. Throws are logged to Console for
// triage but don't surface a UI banner (intentional — silent recovery is
// less disruptive than a red banner for what's typically a transient
// data-shape blip).
function _safeRender(name, fn) {
  try { fn(); }
  catch (e) {
    console.error('[render] ' + name + ' threw:', e);
  }
}
function _changed(key, value, version) {
  var v = version == null ? (RENDER_VERSIONS[key] || 0) : version;
  // Drop the stale-version entry so the cache doesn't grow unbounded across bumps.
  // A version bump must also reset the ref-eq sentinel — otherwise the S3
  // short-circuit below would defeat the R3 cache-bust contract when the
  // caller passes the SAME object reference across a version bump.
  if (_sectionCacheVersions[key] !== undefined && _sectionCacheVersions[key] !== v) {
    delete _sectionCache[key + ':v' + _sectionCacheVersions[key]];
    _lastValueByKey[key] = undefined;
  }
  _sectionCacheVersions[key] = v;
  var cacheKey = key + ':v' + v;
  // Reference-equality short-circuit: skip the stringify entirely when the
  // server returned 304 and the same object reference is being re-checked.
  if (_lastValueByKey[key] === value) {
    if (_lastChangedFlags) _lastChangedFlags[key] = false;
    return false;
  }
  _lastValueByKey[key] = value;
  var json = JSON.stringify(value);
  if (_sectionCache[cacheKey] === json) {
    if (_lastChangedFlags) _lastChangedFlags[key] = false;
    return false;
  }
  _sectionCache[cacheKey] = json;
  if (_lastChangedFlags) _lastChangedFlags[key] = true;
  return true;
}

// Conditional JSON fetch for the heavy per-page endpoints (browser-OOM fix).
// /api/knowledge (~4.5MB) and /api/work-items (~4.2MB) were re-fetched AND
// re-parsed on every 4s refresh tick — ~11MB of allocation churn per tick that
// OOM-crashed the tab during active foreground use. These endpoints emit an
// input-mtime ETag server-side (serveFreshJson + handleKnowledgeList), so send
// If-None-Match off the last-seen ETag and resolve { notModified:true } on a
// 304. Callers skip the parse + render entirely and keep their last-rendered
// state, so an idle dashboard drops from ~11MB/tick to ~0. Routes through
// safeFetch (like the /api/status poll it mirrors) for the 15s abort timeout +
// X-Minions-Dashboard-* tab/visibility correlation headers. Per-URL ETag cache;
// a non-304 error rejects so each call site's existing .catch arm still fires.
var _condFetchEtags = {};
function _condFetchJson(url) {
  var headers = {};
  if (_condFetchEtags[url]) headers['If-None-Match'] = _condFetchEtags[url];
  return safeFetch(url, { headers }).then(function (r) {
    if (r.status === 304) return { notModified: true, data: null };
    if (!r.ok) return Promise.reject(new Error('HTTP ' + r.status));
    var etag = r.headers && r.headers.get ? r.headers.get('etag') : null;
    if (etag) _condFetchEtags[url] = etag;
    return r.json().then(function (data) { return { notModified: false, data: data }; });
  });
}

function _formatCcPowerLabel(autoMode) {
  var runtime = autoMode && autoMode.ccCli ? String(autoMode.ccCli) : 'claude';
  var runtimeLabel = runtime.charAt(0).toUpperCase() + runtime.slice(1);
  var model = autoMode && autoMode.ccModel ? String(autoMode.ccModel) : '';
  return 'Ask anything, dispatch work, manage plans — powered by ' + runtimeLabel + (model ? ' (' + model + ')' : '');
}

function _formatCcDrawerLabel(autoMode) {
  var runtime = autoMode && autoMode.ccCli ? String(autoMode.ccCli) : 'claude';
  var runtimeLabel = runtime.charAt(0).toUpperCase() + runtime.slice(1);
  var model = autoMode && autoMode.ccModel ? String(autoMode.ccModel) : '';
  return runtimeLabel + (model ? ' (' + model + ')' : '') + '-powered. Full minions context. Enter to send, Shift+Enter for newline.';
}

// W-mpnc4u8c001d9d6c / W-mpodheao0006a37a — #engine-quick-stats "Next tick in"
// countdown.
//
// Origin is engine.lastTickAt (stamped by tickInner at the start of every
// tick, see engine.js) plus engine.tickInterval (server-side config, surfaced
// via _buildStatusFastState). The chip updates every 1s without re-rendering
// the surrounding row — mirrors the _tickAgentRuntimes pattern in
// dashboard/js/render-agents.js.
//
// Cadence semantics (W-mpodheao0006a37a): tickInterval is the MINIMUM gap
// between tick starts (engine.js#cli sets `setInterval(() => e.tick(), interval)`
// and engine.tick() short-circuits if a previous tick is still running). Slow
// ticks (PR polls, reconcilers) delay the next slot, so actual tick-to-tick
// gaps can exceed tickInterval. Once the countdown crosses zero the chip
// shows "running" (not "due" — the old label implied the engine was overdue,
// which lies about the floor-not-ceiling semantics). When we have ≥3
// observed deltas in _engineCadenceDeltas, the running label is augmented
// with the observed median cadence: "running (~Ns cadence)".
//
// When the engine isn't running, show "—" so a stale lastTickAt doesn't
// render a ticking countdown the engine can't honor.
var _engineCountdown = { lastTickAt: 0, tickInterval: 0, engineState: 'stopped' };
var _engineNextTickTimer = null;
// Ring buffer of the most recent observed tick-to-tick deltas (ms). Updated
// inside _processStatusUpdate whenever data.engine.lastTickAt advances. Used
// by _formatNextTickText to surface an observed cadence in the overshoot
// path. Capped at _engineCadenceMax entries (FIFO) so the median stays
// representative of recent behavior.
var _engineCadenceDeltas = [];
var _engineCadenceMax = 5;
var _engineCadenceLastSeenTickAt = 0;

function _recordEngineTickObservation(lastTickAt) {
  if (!lastTickAt) return;
  if (!_engineCadenceLastSeenTickAt) {
    _engineCadenceLastSeenTickAt = lastTickAt;
    return;
  }
  if (lastTickAt <= _engineCadenceLastSeenTickAt) return;
  var delta = lastTickAt - _engineCadenceLastSeenTickAt;
  _engineCadenceLastSeenTickAt = lastTickAt;
  _engineCadenceDeltas.push(delta);
  if (_engineCadenceDeltas.length > _engineCadenceMax) _engineCadenceDeltas.shift();
}

function _medianCadenceMs() {
  if (_engineCadenceDeltas.length < 3) return 0;
  var sorted = _engineCadenceDeltas.slice().sort(function(a, b) { return a - b; });
  var mid = Math.floor(sorted.length / 2);
  return sorted.length % 2 ? sorted[mid] : Math.round((sorted[mid - 1] + sorted[mid]) / 2);
}

function _formatNextTickText() {
  var state = _engineCountdown.engineState;
  if (state !== 'running' && state !== 'stopping') return '—';
  var last = _engineCountdown.lastTickAt;
  var interval = _engineCountdown.tickInterval;
  if (!last || !interval) return '—';
  var remainingMs = last + interval - Date.now();
  if (remainingMs > 0) return Math.ceil(remainingMs / 1000) + 's';
  if (state !== 'running') return '—';
  var medianMs = _medianCadenceMs();
  if (medianMs > 0) return 'running (~' + Math.round(medianMs / 1000) + 's cadence)';
  return 'running';
}

function _updateNextTickChip() {
  var el = document.getElementById('engine-next-tick');
  if (!el) {
    if (_engineNextTickTimer) { clearInterval(_engineNextTickTimer); _engineNextTickTimer = null; }
    return;
  }
  var next = _formatNextTickText();
  if (el.textContent !== next) el.textContent = next;
}

function _startNextTickTicker() {
  // Idempotent — refresh.js re-runs every 4s and reseats the data on the
  // shared _engineCountdown; we don't want compounding intervals.
  if (_engineNextTickTimer) return;
  _engineNextTickTimer = setInterval(_updateNextTickChip, 1000);
}

// Background-poll the sidebar-counter summaries from their dedicated
// endpoints (issue #2949). Results land on window._last* globals which
// _pageCounters reads with a /api/status field fallback. Fire-and-forget
// — no awaits — so this never blocks the synchronous render chain. The
// fetched values arrive ~1 tick late on the FIRST refresh after page load;
// subsequent ticks see fresh signatures from the dedicated endpoints with
// no /api/status outer-cache staleness.
// Fire the F1/F3 cross-slice triggers when work-items / pull-requests /
// dispatch resolves with NEW data on this tick. Cross-slice dependency graph:
//   workItems → {prs (+N follow-up chip), plans (derivePlanStatus)}
//   pullRequests → {plans (derivePlanStatus)}
//   dispatch → {plans (derivePlanStatus reads .active/.pending)} (Round-3 #5.)
//
// Signature is a CHEAP fingerprint derived from key fields, NOT a full
// JSON.stringify — the latter allocated multi-MB strings on every tick
// for the typical WI/PR payloads and contradicted the dashboard event-loop
// budget. Catch-fallback uses a STABLE token (not Date.now()) so a payload
// whose getter throws doesn't perpetually re-fire. (Round-4 #1 + #3.)
const _crossSliceSig = { workItems: '', pullRequests: '', dispatch: '' };
function _cheapSig(value) {
  try {
    if (!value) return 'null';
    if (Array.isArray(value)) {
      const head = value[0] || {};
      const tail = value[value.length - 1] || {};
      return value.length + '|' + (head.id || '') + ':' + (head.status || head.state || '') + '|' + (tail.id || '') + ':' + (tail.status || tail.state || head.updatedAt || '');
    }
    if (typeof value === 'object') {
      // Dispatch shape: {pending, active, completed} — fingerprint the lengths
      // + first id of each so any insertion/removal advances the sig.
      const p = Array.isArray(value.pending) ? value.pending : [];
      const a = Array.isArray(value.active) ? value.active : [];
      const c = Array.isArray(value.completed) ? value.completed : [];
      return 'd:' + p.length + ':' + (p[0]?.id || '') + '/' + a.length + ':' + (a[0]?.id || '') + '/' + c.length + ':' + (c[c.length - 1]?.id || '');
    }
    return String(value);
  } catch { return '__err__'; }
}
// F3 coalescer: batch multiple advancing-key calls into ONE renderPlans per
// tick. Without this, a tick where all three of workItems/pullRequests/
// dispatch advance would re-render plans 3-4 times back-to-back for
// identical output. (Round-4 #2.)
let _planRerenderQueued = false;
function _queuePlanRerender() {
  if (_planRerenderQueued) return;
  _planRerenderQueued = true;
  Promise.resolve().then(function () {
    _planRerenderQueued = false;
    if (Array.isArray(window._lastPlans) && typeof renderPlans === 'function') {
      _safeRender('plans:cross-slice', function() { renderPlans(window._lastPlans); });
    }
  });
}
function _fireCrossSliceRender(key, value) {
  const sig = _cheapSig(value);
  if (_crossSliceSig[key] === sig) return false;
  _crossSliceSig[key] = sig;
  // F1 — re-render PRs (+N follow-up chip count depends on the WI list).
  // Fires for both workItems and pullRequests keys so a late PR fetch
  // refreshes the +N chip against the cached WI list. (Round-3 #7.)
  if ((key === 'workItems' || key === 'pullRequests') && Array.isArray(window._lastPullRequests)) {
    _safeRender('prs:cross-slice', function() { renderPrs(window._lastPullRequests); });
  }
  // F3 — coalesced via microtask so 3 advancing keys → 1 plans render.
  _queuePlanRerender();
  return true;
}

function _refreshSidebarCounterSummaries() {
  fetch('/api/qa-runs-summary')
    .then(function (r) { return r.ok ? r.json() : null; })
    .then(function (s) { if (s && typeof s === 'object') window._lastQaRunsSummary = s; })
    .catch(function () { /* sidebar counter degrades to /api/status fallback */ });
  fetch('/api/meetings-total')
    .then(function (r) { return r.ok ? r.json() : null; })
    .then(function (s) { if (s && typeof s.total === 'number') window._lastMeetingsTotal = s.total; })
    .catch(function () { /* optional */ });
  // /api/verify-guides is now fetched in parallel with /api/prd inside the
  // prdProgress block so renderPrd's guideByPlan lookup uses the SAME tick's
  // guide list as the PRD progress data. (Round-2 review finding #5.)
  // QA sessions summary was previously fetched here, but no _pageCounters
  // entry read it — drop the dead endpoint + fetch. (Review finding #9.)
}

function _processStatusUpdate(data, opts) {
  opts = opts || {};
  // Kick off the dedicated-endpoint summary refreshes (issue #2949). They
  // run in the background and update window._last* globals that the sidebar
  // _pageCounters read. No await — the current tick continues immediately.
  _refreshSidebarCounterSummaries();
  // Fresh install id tracking — kept ONLY to update localStorage so the
  // value is available to other code that introspects it; the tab does
  // NOT auto-reload on a MINIONS_HOME swap. User decides when to refresh.
  if (data.installId) {
    localStorage.setItem('minions-install-id', data.installId);
  }
  // Always update cheap elements
  document.getElementById('ts').textContent = formatLocalTime(data.timestamp);
  const engineState = (data.engine && data.engine.state) ? data.engine.state : 'stopped';
  document.getElementById('setup-banner').style.display = (!data.initialized && engineState !== 'stopped') ? 'block' : 'none';
  const autoEl = document.getElementById('auto-approve-badge');
  // eslint-disable-next-line no-unsanitized/property -- reason: composed from compile-time constants (no user data flows in)
  if (autoEl) autoEl.innerHTML = data.autoMode?.approvePlans
    ? '<span style="font-size:var(--text-xs);font-weight:600;padding:1px 6px;border-radius:3px;background:rgba(63,185,80,0.15);color:var(--green);border:1px solid rgba(63,185,80,0.3)">AUTO-APPROVE</span>'
    : '';
  const ccLabelEl = document.getElementById('cmd-powered-by');
  if (ccLabelEl) ccLabelEl.textContent = _formatCcPowerLabel(data.autoMode);
  const ccDrawerLabelEl = document.getElementById('cc-powered-by');
  if (ccDrawerLabelEl) ccDrawerLabelEl.textContent = _formatCcDrawerLabel(data.autoMode);
  const threshEl = document.getElementById('inbox-threshold');
  if (threshEl && data.autoMode?.inboxThreshold) threshEl.textContent = data.autoMode.inboxThreshold;

  // Publish window._last* snapshots BEFORE any renderer runs. Several
  // renderers (renderPrd, renderPrs, derivePlanStatus inside refreshPlans,
  // and the projectChipRemove/optimisticallyAddProject paths off
  // renderProjects) read window._lastStatus / window._lastWorkItems /
  // window._lastDispatch synchronously during their render path. When the
  // assignments lived AFTER the renderer calls (the pre-fix layout in
  // refresh.js:106-108), every tick consumed the PREVIOUS tick's globals —
  // a one-tick (~4 s) staleness lag baked into the polling loop on top of
  // the natural poll interval.
  //
  // See dashboard-refresh-audit.md finding F4 (W-mpgb0x81000cf8df / PR #2753
  // landed the initial hoist; W-mpgbzpn9000390ae extends it with the
  // render-agents / render-work-items invariants below). No renderer below
  // mutates `data`, so hoisting the publish is safe; the renderers
  // themselves still take their slice as a direct argument.
  //
  // Locking-in note (W-mpgbzpn9000390ae): renderAgents
  // (render-agents.js:38) and renderWorkItems (render-work-items.js:98) —
  // the two surfaces the user explicitly called out on /home and /work —
  // take their data slice via argument and DO NOT read window._last*.
  // Hoisting the publish here additionally guarantees that any future
  // refactor that adds a window._last* read to those renderers will see
  // fresh data on the same tick, not the previous one. Covered by
  // dashboard-resilience.test.js source-inspection assertions.
  // window._lastDispatch + window._lastWorkItems are now exclusively set
  // inside the /api/dispatch + /api/work-items .then handlers (issue #2949).
  // The hoist here used to read data.dispatch / data.workItems from the
  // /api/status slim slices — both undefined post-slim — and overwrite the
  // freshly-fetched values from the prior tick with undefined/[], which
  // caused first-tick sidebar-dot flashes on every migrated page.
  window._lastStatus = data;


  // Render every section every tick, each call ISOLATED via _safeRender so a
  // single bad-data renderer can't abort the rest of the chain. Prior bug:
  // an upstream throw (e.g. renderEngineStatus on a partial engine slice)
  // propagated to refresh()'s outer catch, skipping every downstream
  // renderer — including renderWorkItems below — until the offending record
  // cycled out of the slim /api/status payload. Per-call try/catch keeps
  // each renderer independent; throws log to Console (no UI banner).
  //
  // The per-slice _changed(key, data[key]) calls were dropped for every
  // migrated slice (issue #2949) — data[key] is undefined post-slim, so
  // every call fed a permanently-stable 'undefined' signature into the
  // section cache that no consumer reads. The F1/F3 cross-slice triggers
  // that previously gated on _workItemsChanged / _prsChanged moved into
  // the /api/work-items + /api/pull-requests .then handlers
  // (_fireCrossSliceRender) where the FRESH fetched lists are available.
  // Agents now come from /api/agents — a dedicated fresh-JSON endpoint with
  // input-mtime ETag (issue #2949). No /api/status outer cache in the path.
  // cmdUpdateAgentList consumes the same slice but renders the command-line
  // chip pop-up so we feed it the fresh array too.
  _safeRender('agents', function() {
    const seq = (window._refreshSeq = (window._refreshSeq || 0) + 1);
    window._lastRequestedSeq = window._lastRequestedSeq || {};
    window._lastRequestedSeq.agents = seq;
    fetch('/api/agents')
      .then(function (r) { return r.ok ? r.json() : Promise.reject(); })
      .then(function (fresh) {
        if (seq < (window._lastRequestedSeq.agents || 0)) return;
        const list = Array.isArray(fresh) ? fresh : window._lastAgents;
        if (list) window._lastAgents = list;
        _safeRender('agents', function() { renderAgents(list); });
        _safeRender('cmdUpdateAgentList', function() { cmdUpdateAgentList(list); });
      })
      .catch(function () {
        // Re-render against last known good data; leave DOM as-is on cold
        // start. (Review finding #4.)
        if (window._lastAgents) {
          _safeRender('agents', function() { renderAgents(window._lastAgents); });
          _safeRender('cmdUpdateAgentList', function() { cmdUpdateAgentList(window._lastAgents); });
        }
      });
  });
  // PRD slice now comes from /api/prd — a dedicated fresh-JSON endpoint
  // that re-runs getPrdInfo() on every request with input-mtime ETag.
  // Returns { status, progress }. prdProgress + the standalone PRD
  // section share one fetch so they always agree on the same tick.
  // /api/prd returns { status, progress }. prdProgress, the standalone PRD
  // section, and the cross-tick item cache all run inside ONE .then so they
  // see the same fresh data on the same tick — previously the standalone
  // renderPrd block ran synchronously, BEFORE this .then resolved, and
  // always saw the prior tick's stash (or undefined on first render).
  // (Review finding #10.)
  _safeRender('prdProgress', function() {
    // Fetch /api/prd and /api/verify-guides IN PARALLEL so renderPrd's
    // verifyGuides lookup uses the SAME tick's guide list as the PRD
    // progress data. Previously /api/verify-guides was fetched separately
    // by _refreshSidebarCounterSummaries with no ordering guarantee,
    // causing a one-tick lag whenever a fresh guide dropped alongside a
    // PRD status update. (Round-2 review finding #5.)
    const seq = (window._refreshSeq = (window._refreshSeq || 0) + 1);
    window._lastRequestedSeq = window._lastRequestedSeq || {};
    window._lastRequestedSeq.prd = seq;
    Promise.all([
      // Conditional fetch (browser-OOM fix): flatten to null on a 304 or error,
      // which the cached status/progress fallback below already handles. The
      // .catch keeps a prd failure from rejecting the whole Promise.all and
      // starving the verify-guides slice (matches the old `r.ok ? … : null`).
      _condFetchJson('/api/prd').then(function (resp) { return resp.notModified ? null : resp.data; }).catch(function () { return null; }),
      fetch('/api/verify-guides').then(function (r) { return r.ok ? r.json() : null; }),
    ])
      .then(function (results) {
        // Drop late arrivals — out-of-order resolution from a prior tick
        // must not clobber the current tick's window._last* state.
        // (Round-2 review finding #6.)
        if (seq < (window._lastRequestedSeq.prd || 0)) return;
        const prdInfo = results[0];
        const guides = results[1];
        const status = prdInfo && prdInfo.status != null ? prdInfo.status : window._lastPrdStatus;
        const progress = prdInfo && prdInfo.progress != null ? prdInfo.progress : window._lastPrdProgress;
        window._lastPrdStatus = status;
        window._lastPrdProgress = progress;
        if (Array.isArray(guides)) window._lastVerifyGuides = guides;
        _safeRender('prdProgress', function() { renderPrdProgress(progress); });
        _safeRender('cachePrdItems', function() { _cachePrdItems(progress); });
        // renderPrd handles null status/progress gracefully (early-returns
        // on falsy prd, uses progress?.items || []). Calling it
        // unconditionally is required for the "last PRD archived" case to
        // clear the previously-rendered list — round-2 #10's guard was
        // too aggressive and trapped stale content on screen.
        // (Round-3 #3.)
        _safeRender('prd', function() { renderPrd(status, progress); });
      })
      .catch(function () {
        // `!== undefined` (NOT a truthy check) so the cache-was-null
        // post-archive state still triggers a re-render against null —
        // renderPrd's own null-handling paints the empty 'No PRD found'
        // affordance. Truthy gating would conflate cache-was-null with
        // never-fetched. (Round-3 #8.)
        if (window._lastPrdProgress !== undefined) {
          _safeRender('prdProgress', function() { renderPrdProgress(window._lastPrdProgress); });
          _safeRender('cachePrdItems', function() { _cachePrdItems(window._lastPrdProgress); });
          _safeRender('prd', function() { renderPrd(window._lastPrdStatus, window._lastPrdProgress); });
        }
      });
  });
  // Inbox is now sourced from /state/notes/inbox (dir listing + per-file
  // content fetch) instead of the /api/status inbox slice (issue #2949).
  // Falls back to data.inbox when the fetch returns null (offline, network
  // race, dir missing) so the renderer is never starved.
  _safeRender('inbox', function() {
    Promise.resolve(fetchInboxFromDisk())
      .then(function (fresh) {
        const list = fresh && fresh.length ? fresh : (window._lastInbox || []);
        window._lastInbox = list;
        _safeRender('inbox', function() { renderInbox(list); });
      })
      .catch(function () {
        if (window._lastInbox) _safeRender('inbox', function() { renderInbox(window._lastInbox); });
      });
  });
  _changed('projects', data.projects);
  _safeRender('cmdUpdateProjectList', function() { cmdUpdateProjectList(data.projects || []); });
  _safeRender('projects', function() { renderProjects(data.projects || []); });
  // FRE banner — safe to call every tick (idempotent + cheap). Pass the full
  // status payload so the runtime-CLI explainer reads autoMode.defaultCli from
  // THIS tick (window._lastStatus is hoisted above, but renderFre takes the
  // payload directly to avoid the window-global indirection).
  if (typeof renderFre === 'function') {
    _safeRender('fre', function() { renderFre(data); });
  }
  // Notes file (notes.md) is now sourced from /state/notes.md. Same shape
  // as the legacy /api/status notes slice ({ content, updatedAt }); the
  // ETag mtime is parsed out of the response header for updatedAt.
  _safeRender('notes', function() {
    Promise.resolve(fetchNotesFromDisk())
      .then(function (fresh) {
        const notes = fresh || window._lastNotes;
        if (notes) window._lastNotes = notes;
        _safeRender('notes', function() { renderNotes(notes); });
      })
      .catch(function () {
        if (window._lastNotes) _safeRender('notes', function() { renderNotes(window._lastNotes); });
      });
  });
  // _changed('prd', ...) used to gate a now-deleted synchronous renderPrd
  // block. Dropped along with the renderer (issue #2949 + Round-2 finding
  // #1) — leaving a permanently-stable signature in _sectionCache would
  // silently break any future renderer wired to the same key.
  //
  // F1/F3 cross-slice triggers used to be driven by _changed('workItems',
  // data.workItems) + _changed('prs', data.pullRequests). After the slim,
  // data.workItems / data.pullRequests are both undefined every tick so
  // those signatures never advance and the triggers never fire. They are
  // now fired inline from the /api/work-items + /api/pull-requests .then
  // handlers (see _fireCrossSliceRender below) where the FRESH fetched
  // lists are available. (Round-2 review findings #1 + #2.)
  // Pull requests now come from /api/pull-requests — a dedicated fresh-
  // JSON endpoint that re-runs getPullRequests() server-side on every
  // request with input-mtime ETag (issue #2949).
  _safeRender('prs', function() {
    // Stamp the request with a monotonic seq so late arrivals from a
    // prior tick can't clobber fresh state. (Round-2 review finding #6.)
    const seq = (window._refreshSeq = (window._refreshSeq || 0) + 1);
    window._lastRequestedSeq = window._lastRequestedSeq || {};
    window._lastRequestedSeq.prs = seq;
    _condFetchJson('/api/pull-requests')
      .then(function (resp) {
        if (seq < (window._lastRequestedSeq.prs || 0)) return;
        // 304 → PR list unchanged; keep last-rendered state + cross-slice sigs.
        if (resp.notModified) return;
        const fresh = resp.data;
        const list = Array.isArray(fresh) ? fresh : (window._lastPullRequests || []);
        const wasFirstNonEmpty = !Array.isArray(window._lastPullRequests);
        window._lastPullRequests = list;
        _safeRender('prs', function() { renderPrs(list); });
        _fireCrossSliceRender('pullRequests', list);
        // First-arrival case: if /api/work-items already fired its
        // cross-slice trigger before _lastPullRequests was populated, the
        // F1 +N follow-up chip on PRs reads a stale cached count. Force
        // a one-shot re-fire by clearing the workItems sig. (Round-3 #7.)
        if (wasFirstNonEmpty && Array.isArray(window._lastWorkItems)) {
          _crossSliceSig.workItems = '';
          _fireCrossSliceRender('workItems', window._lastWorkItems);
        }
      })
      .catch(function () {
        if (Array.isArray(window._lastPullRequests)) _safeRender('prs', function() { renderPrs(window._lastPullRequests); });
      });
  });
  // Archived PRDs come from /api/archived-prds (issue #2949).
  _safeRender('archiveButtons', function() {
    fetch('/api/archived-prds')
      .then(function (r) { return r.ok ? r.json() : Promise.reject(); })
      .then(function (fresh) {
        const list = Array.isArray(fresh) ? fresh : (window._lastArchivedPrds || []);
        window._lastArchivedPrds = list;
        _safeRender('archiveButtons', function() { renderArchiveButtons(list); });
      })
      .catch(function () {
        if (Array.isArray(window._lastArchivedPrds)) _safeRender('archiveButtons', function() { renderArchiveButtons(window._lastArchivedPrds); });
      });
  });
  _changed('engine', data.engine);
  if (data.engine) _safeRender('engineStatus', function() { renderEngineStatus(data.engine); });
  _safeRender('engineQuickStats', function() {
    var qs = document.getElementById('engine-quick-stats');
    if (qs && data.engine) {
      var wt = data.engine.worktreeCount != null ? data.engine.worktreeCount : '-';
      var pid = data.engine.pid || '-';
      // W-mpnc4u8c001d9d6c — replace the dead "Tick: -" chip (control.json
      // never carried a `tick` field) with a live "Next tick in Xs" countdown
      // driven by engine.lastTickAt (stamped at the start of every tickInner)
      // and engine.tickInterval (config, surfaced in the status payload).
      // _updateNextTickChip below ticks the inner span every 1s without
      // re-rendering this whole row.
      _engineCountdown.lastTickAt = Number(data.engine.lastTickAt) || 0;
      _engineCountdown.tickInterval = Number(data.engine.tickInterval) || 0;
      _engineCountdown.engineState = data.engine.state || 'stopped';
      // Feed the cadence ring buffer so the overshoot label can surface the
      // observed tick-to-tick gap (W-mpodheao0006a37a).
      _recordEngineTickObservation(_engineCountdown.lastTickAt);
      // eslint-disable-next-line no-unsanitized/property -- reason: composed from internal engine metrics (pid, lastTickAt/tickInterval, worktreeCount) and a literal id; no user data flows in
      qs.innerHTML = '<span>PID: <b>' + pid + '</b></span>' +
        '<span>Next tick in: <b id="engine-next-tick">' + _formatNextTickText() + '</b></span>' +
        '<span>Worktrees: <b>' + wt + '</b></span>';
      _startNextTickTicker();
    }
  });
  _changed('version', data.version);
  _safeRender('versionBanner', function() { renderVersionBanner(data.version); });
  _changed('adoThrottle', data.adoThrottle);
  _safeRender('adoThrottle', function() { renderAdoThrottleAlert(data.adoThrottle); });
  _changed('ghThrottle', data.ghThrottle);
  _safeRender('ghThrottle', function() { renderGhThrottleAlert(data.ghThrottle); });
  // Dispatch now comes from /api/dispatch — a dedicated fresh-JSON
  // endpoint that re-runs getDispatchQueue() server-side on every
  // request (issue #2949). Completion-report sidecars are now loaded
  // server-side too, so the client no longer needs the cached overlay.
  // Scroll-preservation opts (preserveCompletedScroll) are threaded
  // through to renderDispatch so the completed-dispatch pane keeps its
  // scroll position across refreshes (W-mpp75wkg000b3018).
  _safeRender('dispatch', function() {
    const dispatchOpts = { preserveCompletedScroll: opts.preserveCompletedScroll };
    const seq = (window._refreshSeq = (window._refreshSeq || 0) + 1);
    window._lastRequestedSeq = window._lastRequestedSeq || {};
    window._lastRequestedSeq.dispatch = seq;
    _condFetchJson('/api/dispatch')
      .then(function (resp) {
        if (seq < (window._lastRequestedSeq.dispatch || 0)) return;
        // 304 → dispatch queue unchanged; keep last-rendered panes + scroll.
        if (resp.notModified) return;
        const d = resp.data || window._lastDispatch;
        if (d) window._lastDispatch = d;
        _safeRender('dispatch', function() { renderDispatch(d, dispatchOpts); });
        // Dispatch is the third cross-slice edge: derivePlanStatus reads
        // window._lastDispatch.active/.pending for executing/converting
        // plan-status badges. Without this trigger, plan-status pills lag
        // by ticks when a dispatch is added/completed but no WI or PR
        // delta accompanies it. (Round-3 #5.)
        _fireCrossSliceRender('dispatch', d);
      })
      .catch(function () {
        if (window._lastDispatch) _safeRender('dispatch', function() { renderDispatch(window._lastDispatch, dispatchOpts); });
      });
  });
  // prunePrdRequeueState moved into the /api/work-items .then handler so it
  // operates on the fresh fetched list rather than the synchronously-empty
  // window._lastWorkItems. (Review finding #5.)
  // Engine log (last 50 entries) comes from /state/engine/log.json with a
  // client-side .slice(-50) to mirror queries.js getEngineLog (issue #2949).
  // Scroll-preservation opts (preserveEngineLogScroll) are threaded through
  // to renderEngineLog so the log pane keeps its scroll position across
  // refreshes (W-mpp75wkg000b3018).
  _safeRender('engineLog', function() {
    const logOpts = { preserveEngineLogScroll: opts.preserveEngineLogScroll };
    Promise.resolve(fetchEngineLogFromDisk())
      .then(function (fresh) {
        const list = fresh && fresh.length ? fresh : (window._lastEngineLog || []);
        window._lastEngineLog = list;
        _safeRender('engineLog', function() { renderEngineLog(list, logOpts); });
      })
      .catch(function () {
        if (Array.isArray(window._lastEngineLog)) _safeRender('engineLog', function() { renderEngineLog(window._lastEngineLog, logOpts); });
      });
  });
  // Metrics now come from /api/metrics (issue #2949) — the enrichment
  // join (PR counts, runtime totals) runs server-side fresh on every
  // request with input-mtime ETag.
  _safeRender('metrics', function() {
    // Seq guard: metrics.json is mutated on every dispatch completion +
    // PR/build state change via trackEngineUsage. Out-of-order tick
    // resolution could revert tasksCompleted / USD spend counters to the
    // pre-completion state. (Round-3 #10.)
    const seq = (window._refreshSeq = (window._refreshSeq || 0) + 1);
    window._lastRequestedSeq = window._lastRequestedSeq || {};
    window._lastRequestedSeq.metrics = seq;
    fetch('/api/metrics')
      .then(function (r) { return r.ok ? r.json() : Promise.reject(); })
      .then(function (fresh) {
        if (seq < (window._lastRequestedSeq.metrics || 0)) return;
        const m = fresh && typeof fresh === 'object' ? fresh : (window._lastMetrics || {});
        window._lastMetrics = m;
        _safeRender('metrics', function() { renderMetrics(m); });
      })
      .catch(function () {
        if (window._lastMetrics) _safeRender('metrics', function() { renderMetrics(window._lastMetrics); });
      });
  });
  // managed-processes panel — ETag-gated so unchanged ticks return 304 with
  // no body (P-6e2a8b13). Sequenced BEFORE the keep-processes call below via
  // .then() so the keep renderer reads a populated managed-PID cache for
  // dedup (W-mpdad3mq000m53bb). _processStatusUpdate isn't async, so we
  // chain on the returned Promise instead of awaiting.
  let _managedRender = Promise.resolve();
  if (typeof renderManagedProcesses === 'function') {
    try { _managedRender = Promise.resolve(renderManagedProcesses()); } catch {}
  }
  // keep_processes panel renders on every page where its mount is in the DOM.
  // Cheap call (one fetch); the renderer iterates all registered mounts, skips
  // when none are present, and suppresses any PID already tracked as a managed
  // process via MinionsManagedProcesses.getLastItems().
  if (typeof renderKeepProcesses === 'function') {
    _managedRender
      .catch(function () { /* keep render even if managed fetch failed — getLastItems() returns the last good cache (or []) */ })
      .then(function () { try { renderKeepProcesses(); } catch {} });
  }
  // Work items now come from /api/work-items — a dedicated fresh-JSON
  // endpoint that re-runs getWorkItems() server-side on every request
  // with input-mtime ETag (issue #2949). The previous /state/ + stale-
  // enrichment-overlay path is gone: enrichment runs server-side fresh,
  // so _pr/_prUrl/_pendingReason/_skipReason are guaranteed current.
  _safeRender('workItems', function() {
    // Stamp the request with a monotonic seq — late arrivals from a prior
    // tick can't clobber fresh state. (Round-2 review finding #6.)
    const seq = (window._refreshSeq = (window._refreshSeq || 0) + 1);
    window._lastRequestedSeq = window._lastRequestedSeq || {};
    window._lastRequestedSeq.workItems = seq;
    _condFetchJson('/api/work-items')
      .then(function (resp) {
        if (seq < (window._lastRequestedSeq.workItems || 0)) return;
        // 304 → list unchanged; keep the last-rendered rows + DOM untouched.
        if (resp.notModified) return;
        const fresh = resp.data;
        const list = Array.isArray(fresh) ? fresh : (window._lastWorkItems || []);
        window._lastWorkItems = list;
        _safeRender('workItems', function() { renderWorkItems(list); });
        // prunePrdRequeueState reads the FRESH fetched list rather than the
        // synchronously-empty window._lastWorkItems. (Review finding #5.)
        _safeRender('prunePrdRequeueState', function() { prunePrdRequeueState(list); });
        // F1/F3 cross-slice trigger fires here so the +N follow-up chip on
        // PRs and the plan-status badges update when WI state actually
        // changes. (Round-2 review findings #1 + #2.)
        _fireCrossSliceRender('workItems', list);
      })
      .catch(function () {
        // Re-render against the last known good list rather than blanking
        // the panel. Skip prunePrdRequeueState entirely on the catch arm —
        // calling it against a stale list could delete optimistic UI chips
        // the user just set between the fetch dispatch and the failure.
        // (Round-2 review finding #8.)
        if (Array.isArray(window._lastWorkItems)) {
          _safeRender('workItems', function() { renderWorkItems(window._lastWorkItems); });
        }
      });
  });
  _changed('skills', data.skills);
  _safeRender('skills', function() { renderSkills(data.skills || []); });
  _changed('mcpServers', data.mcpServers);
  _safeRender('mcpServers', function() { renderMcpServers(data.mcpServers || []); });
  // Schedule definitions stay on /api/status (config-derived), but the
  // _lastRun/_lastResult overlay is re-applied client-side from
  // /state/engine/schedule-runs.json so a freshly-fired schedule lights up
  // within one 4 s poll (issue #2949).
  // Schedules now come from /api/schedules — a dedicated fresh-JSON endpoint
  // that merges config.schedules with schedule-runs.json server-side, ETag
  // off input mtimes (issue #2949).
  _safeRender('schedules', function() {
    fetch('/api/schedules')
      .then(function (r) { return r.ok ? r.json() : Promise.reject(); })
      .then(function (fresh) {
        const list = Array.isArray(fresh) ? fresh : (data.schedules || []);
        window._lastSchedules = list;
        _safeRender('schedules', function() { renderSchedules(list); });
      })
      .catch(function () {
        if (Array.isArray(window._lastSchedules)) _safeRender('schedules', function() { renderSchedules(window._lastSchedules); });
      });
  });
  // Watches array comes from /state/engine/watches.json (issue #2949).
  _safeRender('watches', function() {
    // Seq guard — watches.json is mutated every 3 ticks by engine/watches.js
    // (mutateWatches on watch-fire / pause). (Round-4 #10.)
    const seq = (window._refreshSeq = (window._refreshSeq || 0) + 1);
    window._lastRequestedSeq = window._lastRequestedSeq || {};
    window._lastRequestedSeq.watches = seq;
    Promise.resolve(fetchWatchesFromDisk())
      .then(function (fresh) {
        if (seq < (window._lastRequestedSeq.watches || 0)) return;
        const list = fresh && fresh.length ? fresh : (window._lastWatches || []);
        window._lastWatches = list;
        _safeRender('watches', function() { renderWatches(list); });
      })
      .catch(function () {
        if (Array.isArray(window._lastWatches)) _safeRender('watches', function() { renderWatches(window._lastWatches); });
      });
  });
  // Meetings come from /state/meetings (dir listing + per-file fetch),
  // slimmed client-side to match dashboard.js _slimMeetingForStatus.
  // Issue #2949 — same outer-cache staleness fix as work-items + PRs.
  _safeRender('meetings', function() {
    Promise.resolve(fetchMeetingsFromDisk())
      .then(function (fresh) {
        const list = fresh && fresh.length ? fresh : (window._lastMeetings || []);
        window._lastMeetings = list;
        _safeRender('meetings', function() { renderMeetings(list); });
      })
      .catch(function () {
        if (Array.isArray(window._lastMeetings)) _safeRender('meetings', function() { renderMeetings(window._lastMeetings); });
      });
  });
  if (typeof renderPipelines === 'function') {
    // Definitions stay on /api/status, runs overlay (last 5 per pipeline)
    // is re-applied client-side from /state/engine/pipeline-runs.json so a
    // newly-started run lights the panel within one 4 s poll (issue #2949).
    // Pipelines now come from /api/pipelines — a dedicated fresh-JSON
    // endpoint that merges pipelines/*.json with pipeline-runs.json
    // (last 5 runs per pipeline) server-side (issue #2949).
    _safeRender('pipelines', function() {
      // Seq guard — pipeline-runs.json is mutated on every pipeline run
      // start/completion; out-of-order resolution could revert run state.
      // (Round-4 #10.)
      const seq = (window._refreshSeq = (window._refreshSeq || 0) + 1);
      window._lastRequestedSeq = window._lastRequestedSeq || {};
      window._lastRequestedSeq.pipelines = seq;
      fetch('/api/pipelines')
        .then(function (r) { return r.ok ? r.json() : Promise.reject(); })
        .then(function (fresh) {
          if (seq < (window._lastRequestedSeq.pipelines || 0)) return;
          const list = Array.isArray(fresh) ? fresh : (window._lastPipelines || []);
          window._lastPipelines = list;
          _safeRender('pipelines', function() { renderPipelines(list); });
        })
        .catch(function () {
          if (Array.isArray(window._lastPipelines)) _safeRender('pipelines', function() { renderPipelines(window._lastPipelines); });
        });
    });
  }
  // Pinned notes come from /api/pinned (issue #2949).
  _safeRender('pinned', function() {
    // Seq guard — pinned.md is user-mutated via POST /api/pinned. (Round-4 #10.)
    const seq = (window._refreshSeq = (window._refreshSeq || 0) + 1);
    window._lastRequestedSeq = window._lastRequestedSeq || {};
    window._lastRequestedSeq.pinned = seq;
    fetch('/api/pinned')
      .then(function (r) { return r.ok ? r.json() : Promise.reject(); })
      .then(function (fresh) {
        if (seq < (window._lastRequestedSeq.pinned || 0)) return;
        const list = Array.isArray(fresh) ? fresh : (window._lastPinned || []);
        window._lastPinned = list;
        _safeRender('pinned', function() { renderPinned(list); });
      })
      .catch(function () {
        if (Array.isArray(window._lastPinned)) _safeRender('pinned', function() { renderPinned(window._lastPinned); });
      });
  });
  // Sidebar counts — read from the dedicated-endpoint caches; data.workItems
  // / data.pullRequests are undefined post-slim. Renders empty until the
  // first /api/work-items + /api/pull-requests fetches resolve.
  const swi = document.getElementById('sidebar-wi');
  if (swi) swi.textContent = (window._lastWorkItems || []).length || '';
  const spr = document.getElementById('sidebar-pr');
  if (spr) spr.textContent = (window._lastPullRequests || []).length || '';
  // Refresh KB and plans every status cycle (~4s) so plan status flips
  // (approve/archive/complete) and KB additions surface within the SPA's
  // 4s poll target. Server-side caches keep this cheap:
  //   - /api/plans: 5s in-memory TTL + invalidation on every mutating endpoint
  //     (dashboard.js _plansCache / invalidatePlansCache) — back-to-back polls
  //     hit the cache; external file edits surface within 5s.
  //   - /api/knowledge: 30s in-memory TTL + invalidation on POST /api/knowledge
  //     and after every kb-sweep (engine/queries.js _kbCache / kb-sweep.js).
  // Previously throttled to every 3rd cycle (~12s) — see W-mphfb6ss000a3b9e
  // for the cadence audit + Playwright coverage.
  _safeRender('refreshKnowledgeBase', function() { refreshKnowledgeBase(); });
  _safeRender('refreshPlans', function() { refreshPlans(); });

  // F1/F3 cross-slice triggers moved into the /api/work-items + /api/pull-
  // requests .then handlers (see _fireCrossSliceRender helper). The
  // synchronous-tick versions used to fire off _changed signatures derived
  // from data.workItems / data.pullRequests, both undefined after the
  // /api/status slim. (Round-2 review findings #1 + #2.)

  // Sidebar activity indicators — show red dot on pages with new activity
  try {
    const changes = _detectPageChanges(data);
    for (const page of Object.keys(changes)) {
      if (currentPage === page) continue; // don't badge the active page
      const link = document.querySelector('.sidebar-link[data-page="' + page + '"]');
      if (link && !link.querySelector('.notif-badge')) showNotifBadge(link, 'done');
    }
  } catch { /* expected on first load */ }
}

// Auto-reload policy (2026-05-29):
// Reload only on dashboardStartedAt change (signal that `minions restart`
// happened and the process is new). dashboardBuildId / runningCommit /
// installId mismatch DO NOT auto-reload anymore — code edits and engine
// commits must not silently interrupt the operator's open session.
let _knownDashboardStartId = null;
// /api/status ETag cache (W-mpehsyhv0017085a). The dashboard polls every 4 s
// but the server-side cache only changes every 10–60 s. Sending If-None-Match
// lets the server short-circuit ~60 %+ of polls into a 304 with no body —
// drops the 7.7 MB JSON.stringify + gzipSync off the dashboard event loop on
// the hot path. Falls back to the cached payload on 304; transparent to all
// downstream consumers of `data`.
let _lastStatusEtag = null;
let _lastStatusData = null;
// In-flight gate (S5 — dashboard-refresh-audit). setInterval(refresh, 4000)
// can interleave two ticks inside _processStatusUpdate if a refresh takes
// >4 s (cold server cache + slow JSON + network blip). Skip the second tick
// when the first hasn't finished — the next interval fire picks up the
// fresh state anyway.
let _refreshInFlight = false;

// ── Dashboard-unreachable detector ───────────────────────────────────────
// When the dashboard process dies, /api/status throws or 5xxs and the
// existing catch block just console.errors — the page keeps painting the
// last successful snapshot. Operators have reported the resulting symptom
// many times: badge says "running", CC POST throws "Failed to fetch", and
// it's not obvious the dashboard itself is dead (vs. wedged, vs. the
// engine being down).
//
// Trip conditions: 2 consecutive failed polls OR >12 s since the last
// success (3× the 4 s poll cadence, so a single flaky tick doesn't fire).
// On trip we show a sticky red banner with two recovery actions:
//   1. "Restart Minions"  → ccRestartMinions (works when dashboard is
//      alive-but-stale; falls through to copy-to-clipboard when the POST
//      itself fails)
//   2. "Copy minions restart" → terminal fallback for the dashboard-is-
//      truly-dead case the in-browser button can't fix on its own
// Also overrides the engine badge to UNKNOWN/muted so the misleading
// "RUNNING" pill stops showing while data is frozen.
let _lastStatusOkAt = Date.now();
let _consecutiveStatusFails = 0;
let _unreachableSince = 0; // 0 = currently reachable
let _unreachableAgeTimer = null;
// 3 + 20s (was 2 + 12s) — the prior thresholds tripped a banner on a single
// safeFetch abort (timeout 15s in state.js) because the OR'd age side was
// already satisfied. With safeFetch=15s, the age budget needs >15s to avoid
// the trip-on-first-slow-response footgun. 3 consecutive fails ≈ 12s of real
// outage at the 4s poll cadence, which still surfaces the banner promptly
// during a real dashboard crash but tolerates one slow rebuild + retry.
const _UNREACHABLE_FAIL_THRESHOLD = 3;
const _UNREACHABLE_AGE_MS = 20000;
// Once the banner is up we throttle polls (exponential, capped at 30s) to
// avoid hammering a struggling dashboard / network. Reset on recovery.
let _nextPollAllowedAt = 0;

function _formatAge(ms) {
  if (ms < 1000) return 'just now';
  const s = Math.round(ms / 1000);
  if (s < 60) return s + 's ago';
  const m = Math.floor(s / 60);
  const rem = s % 60;
  return rem ? m + 'm ' + rem + 's ago' : m + 'm ago';
}

function _refreshUnreachableAgeText() {
  if (!_unreachableSince) return;
  // The age span is rendered into the shared #engine-alert element by
  // _markDashboardUnreachable; lookup by ID survives any inner-HTML rebuild
  // as long as the markup keeps the span around (it does — single source).
  const el = document.getElementById('dashboard-unreachable-age');
  if (el) el.textContent = _formatAge(Date.now() - _lastStatusOkAt);
}

function _markDashboardUnreachable(err) {
  if (_unreachableSince) {
    _refreshUnreachableAgeText();
    return;
  }
  _unreachableSince = Date.now();
  window._dashboardUnreachable = {
    since: _unreachableSince,
    lastSuccessAt: _lastStatusOkAt,
    lastError: String(err && err.message || err || 'unknown'),
  };
  // Reuse the existing #engine-alert surface (red-tinted banner already wired
  // for engine-stale, ado-throttle, gh-throttle) instead of introducing a
  // second red banner. Engine-stale and dashboard-unreachable are mutually
  // exclusive in practice: engine-stale needs a fresh heartbeat from a
  // successful poll, and a successful poll means the dashboard IS reachable.
  // When the dashboard recovers, _markDashboardReachable hides this element
  // explicitly; the next renderEngineAlert pass (driven by the recovered
  // poll's engine state) then takes over normally.
  const el = document.getElementById('engine-alert');
  if (el) {
    el.innerHTML =
      '<span class="engine-alert-msg">&#x26A0;&#xFE0F; Dashboard unreachable &mdash; stale <span id="dashboard-unreachable-age">just now</span></span>' +
      '<span class="engine-alert-action" onclick="ccRestartMinions(this)">Restart Minions</span>';
    el.style.display = 'flex';
  }
  _refreshUnreachableAgeText();
  if (_unreachableAgeTimer) clearInterval(_unreachableAgeTimer);
  _unreachableAgeTimer = setInterval(_refreshUnreachableAgeText, 1000);
  // Override engine badge so the cached "RUNNING" doesn't keep misleading
  // the user. renderEngineStatus reads engine state from the next
  // successful poll — when reachable recovers, the override clears.
  const badge = document.getElementById('engine-badge');
  if (badge) {
    badge.className = 'engine-badge stopped';
    badge.textContent = 'UNKNOWN';
    badge.title = 'Dashboard unreachable — engine state is unknown. UI data is stale.';
  }
  console.warn('Dashboard unreachable:', window._dashboardUnreachable.lastError);
}

function _markDashboardReachable() {
  if (!_unreachableSince) return;
  const downForMs = Date.now() - _unreachableSince;
  _unreachableSince = 0;
  delete window._dashboardUnreachable;
  if (_unreachableAgeTimer) { clearInterval(_unreachableAgeTimer); _unreachableAgeTimer = null; }
  // Hand #engine-alert back to renderEngineAlert. We can't leave our content
  // sitting in there because the next renderEngineAlert call only runs when
  // `_changed('engine', data.engine)` returns true — if engine state hasn't
  // shifted, our stale banner would persist past recovery.
  const el = document.getElementById('engine-alert');
  if (el) { el.style.display = 'none'; el.innerHTML = ''; }
  console.log('Dashboard recovered after', _formatAge(downForMs).replace(' ago', ''));
  // Badge restoration happens automatically on the next renderEngineStatus
  // call (triggered by the successful refresh that brought us here).
}

// Test seam — reset detector state between scenarios.
window._resetDashboardUnreachableForTest = function() {
  _lastStatusOkAt = Date.now();
  _consecutiveStatusFails = 0;
  _unreachableSince = 0;
  _nextPollAllowedAt = 0;
  if (_unreachableAgeTimer) { clearInterval(_unreachableAgeTimer); _unreachableAgeTimer = null; }
  delete window._dashboardUnreachable;
};

// Visibility wake-up: Chromium throttles setInterval on hidden tabs to ~1/min,
// so _lastStatusOkAt can drift far past the age threshold while the tab is
// backgrounded. When the user refocuses, state.js fires refresh() (line 206)
// — without this reset the age side of the OR is already satisfied and a
// single transient post-wake failure (DNS not yet up after suspend, mid-
// restart server) trips the banner before any real evidence of trouble.
// Reset BEFORE state.js calls refresh() — listener order is fine because
// both fire on the same event; this listener registered first runs first.
document.addEventListener('visibilitychange', function() {
  if (document.visibilityState === 'visible' && !_unreachableSince) {
    _lastStatusOkAt = Date.now();
    _consecutiveStatusFails = 0;
  }
});

// ── Refresh diagnostics (W-mphejzx100081972) ─────────────────────────────
// Ring buffer capturing the last 50 /api/status poll cycles so a user
// reporting "the dashboard didn't auto-update when X changed" can paste
// `window._refreshDiagnostics()` from devtools or click the "diag" footer
// chip to surface a table. Gated behind feature flag
// `dashboard-refresh-diagnostics` (engine/features.js) — disabled in
// production, enabled in dev. When disabled, the helpers below are no-ops
// (the boolean check is the first line of every collection path) so the
// steady-state poll loop is byte-identical to its pre-flag behaviour.
const _DIAG_RING_SIZE = 50;
const _refreshDiagBuf = [];
let _prevRefreshTs = 0;
let _diagHiddenSinceMs = 0; // ts when tab last went hidden; 0 when visible
let _lastVisibilityChangeAt = 0;
function _isRefreshDiagOn() {
  try {
    return !!(window.MinionsFeatures && window.MinionsFeatures.isOn('dashboard-refresh-diagnostics'));
  } catch { return false; }
}
function _pushDiagEntry(entry) {
  _refreshDiagBuf.push(entry);
  if (_refreshDiagBuf.length > _DIAG_RING_SIZE) _refreshDiagBuf.shift();
}
// Expose a copy of the ring buffer so devtools paste-back captures a
// stable snapshot — not a live reference the user could accidentally mutate.
window._refreshDiagnostics = function() { return _refreshDiagBuf.slice(); };
window._refreshDiagnosticsClear = function() { _refreshDiagBuf.length = 0; };
document.addEventListener('visibilitychange', function() {
  if (!_isRefreshDiagOn()) return;
  const now = Date.now();
  const vs = document.visibilityState;
  let hiddenForMs = null;
  if (vs === 'visible' && _diagHiddenSinceMs > 0) {
    hiddenForMs = now - _diagHiddenSinceMs;
    _diagHiddenSinceMs = 0;
  } else if (vs === 'hidden') {
    _diagHiddenSinceMs = now;
  }
  _pushDiagEntry({
    ts: now,
    kind: 'visibility',
    visibility: vs,
    hidden_for_ms: hiddenForMs,
    gap_since_prev_ms: _lastVisibilityChangeAt ? now - _lastVisibilityChangeAt : null,
  });
  _lastVisibilityChangeAt = now;
});

async function refresh(opts) {
  opts = opts || {};
  if (_refreshInFlight) return;
  // Backoff gate — only active while the unreachable banner is up. Skips
  // setInterval ticks until _nextPollAllowedAt is reached so a downed
  // dashboard isn't hammered at the steady 4s cadence (which produces
  // console-spam and adds load to whatever's wedged).
  if (_nextPollAllowedAt && Date.now() < _nextPollAllowedAt) return;
  _refreshInFlight = true;
  const _diagOn = _isRefreshDiagOn();
  const _t0 = _diagOn ? Date.now() : 0;
  const _gap = _diagOn && _prevRefreshTs ? _t0 - _prevRefreshTs : null;
  if (_diagOn) _prevRefreshTs = _t0;
  const _diagEntry = _diagOn ? {
    ts: _t0,
    kind: 'refresh',
    gap_since_prev_ms: _gap,
    visibility: (typeof document !== 'undefined' && document.visibilityState) || null,
    response_status: null,
    etag_sent: _lastStatusEtag,
    etag_received: null,
    bytes_received: null,
    statusCacheVersion: null,
    workItems_changed: null,
    workItems_count: null,
    changed: null,
    render_duration_ms: null,
    error_message: null,
  } : null;
  try {
    const headers = {};
    if (_lastStatusEtag) headers['If-None-Match'] = _lastStatusEtag;
    const res = await safeFetch('/api/status', { headers });
    if (!res || (!res.ok && res.status !== 304)) {
      // Dashboard responded but with an error status. Treat as a fail tick so
      // a 5xx-storm trips the unreachable banner just like a network error.
      throw new Error('HTTP ' + (res ? res.status : '?'));
    }
    let data;
    if (res.status === 304 && _lastStatusData) {
      // Cache hit — reuse last payload, skip parsing entirely.
      data = _lastStatusData;
      if (_diagEntry) {
        _diagEntry.response_status = '304';
        _diagEntry.bytes_received = 0;
        // D2: capture etag on 304 so the diag table can show whether the
        // server's ETag advanced even when we're reusing the cached body.
        // Without this the "etag↓" column is blank on every 304 row and the
        // operator can't tell server-side advancement from a pinned cache.
        const etag304 = res.headers && (res.headers.get ? res.headers.get('etag') : null);
        if (etag304) _diagEntry.etag_received = etag304;
      }
    } else {
      data = await res.json();
      const etag = res.headers && (res.headers.get ? res.headers.get('etag') : null);
      if (etag) {
        _lastStatusEtag = etag;
        _lastStatusData = data;
      }
      if (_diagEntry) {
        _diagEntry.response_status = String(res.status);
        _diagEntry.etag_received = etag || null;
        const cl = res.headers && (res.headers.get ? res.headers.get('content-length') : null);
        _diagEntry.bytes_received = cl != null && cl !== '' ? Number(cl) : null;
      }
    }
    // Auto-reload policy (2026-05-29):
    //   - dashboardStartedAt change → reload  (covers `minions restart`)
    //   - dashboardBuildId change   → DO NOT reload  (dashboard/** edits
    //     while dev'ing the UI must not silently kick connected tabs)
    //   - runningCommit change      → DO NOT reload  (engine git-pull is
    //     not a dashboard restart — engine code change doesn't justify
    //     interrupting an open dashboard session)
    //   - installId change          → DO NOT reload  (MINIONS_HOME swap
    //     is the operator's call to refresh, not ours)
    //
    // The user explicitly wants `minions restart` to reload the tab so
    // they pick up any updated code; they do NOT want any other code-
    // change signal to trigger a reload.
    const dashId = (data.version && data.version.dashboardStartedAt) || null;
    if (dashId && _knownDashboardStartId && dashId !== _knownDashboardStartId) {
      console.log('Dashboard restarted — reloading page');
      location.reload();
      return;
    }
    if (dashId) _knownDashboardStartId = dashId;
    // Successful poll — clear unreachable state.
    _lastStatusOkAt = Date.now();
    _consecutiveStatusFails = 0;
    _nextPollAllowedAt = 0;
    if (_unreachableSince) _markDashboardReachable();
    const _renderStart = _diagOn ? Date.now() : 0;
    let _diagChanges = null;
    if (_diagOn) {
      _diagChanges = {};
      _lastChangedFlags = _diagChanges;
    }
    try {
      _processStatusUpdate(data, opts);
    } finally {
      if (_diagOn) {
        _lastChangedFlags = null;
        _diagEntry.render_duration_ms = Date.now() - _renderStart;
        _diagEntry.changed = _diagChanges;
        _diagEntry.workItems_changed = !!(_diagChanges && _diagChanges.workItems);
        // Post-slim, data.workItems is undefined; read from the dedicated
        // endpoint cache.
        _diagEntry.workItems_count = (window._lastWorkItems || []).length;
        _diagEntry.statusCacheVersion = (data.version && data.version.statusCacheVersion) != null
          ? data.version.statusCacheVersion
          : null;
      }
    }
  } catch(e) {
    console.error('refresh error', e);
    if (_diagEntry && !_diagEntry.response_status) {
      _diagEntry.response_status = (e && e.name === 'AbortError') ? 'abort' : 'error';
      _diagEntry.error_message = String((e && e.message) || e);
    }
    _consecutiveStatusFails++;
    const ageMs = Date.now() - _lastStatusOkAt;
    if (_consecutiveStatusFails >= _UNREACHABLE_FAIL_THRESHOLD || ageMs > _UNREACHABLE_AGE_MS) {
      _markDashboardUnreachable(e);
    }
    // Backoff: once we've tripped the banner, throttle subsequent polls
    // (4s → 8s → 16s → 30s cap). Reset to 0 in the success path below so
    // recovery snaps back to the steady 4s cadence on the next tick.
    if (_unreachableSince) {
      const failsSinceTrip = Math.max(1, _consecutiveStatusFails - _UNREACHABLE_FAIL_THRESHOLD + 1);
      const backoffMs = Math.min(30000, 4000 * Math.pow(2, failsSinceTrip - 1));
      _nextPollAllowedAt = Date.now() + backoffMs;
    }
  }
  finally {
    _refreshInFlight = false;
    if (_diagEntry) _pushDiagEntry(_diagEntry);
  }
}

refresh();
_syncPinsFromServer(); // Load server-side pins on startup
// Gate the "Try new Slim UX" promo button behind the slim-ux-promo feature
// flag (off by default). The button ships hidden in layout.html; reveal it
// only when the flag is on. The classic HTML (and its window.MINIONS_FEATURES
// bootstrap) is built once at dashboard startup, so a runtime flag toggle
// wouldn't be reflected there — read the LIVE flag state from /api/features
// instead so flipping the flag takes effect on reload with no restart.
(function _gateSlimUxPromoButton() {
  fetch('/api/features').then(function(r) { return r.ok ? r.json() : null; }).then(function(data) {
    if (!data || !Array.isArray(data.features)) return;
    var promo = data.features.find(function(f) { return f.id === 'slim-ux-promo'; });
    var btn = document.getElementById('try-slim-ux-btn');
    if (btn && promo && promo.enabled) btn.style.display = '';
  }).catch(function() { /* offline / error — leave the button hidden */ });
})();
// W-mpmwxkrw000872ec — reconcile the font-size preference from the server
// once on cold load. The inline bootstrap in layout.html has already applied
// localStorage's value (if any) to avoid flash; this fetch promotes the
// server value when it differs (e.g. fresh browser, different machine).
(function _reconcileFontSizeFromServer() {
  fetch('/api/settings').then(function(r) { return r.ok ? r.json() : null; }).then(function(data) {
    if (!data || !data.engine) return;
    var serverValue = data.engine.fontSize || 'small';
    var localValue = 'small';
    try { localValue = localStorage.getItem('minions:font-size') || 'small'; } catch (e) { /* private mode */ }
    if (serverValue !== localValue && typeof window.applyFontSizePreference === 'function') {
      window.applyFontSizePreference(serverValue);
    }
  }).catch(function() { /* offline / 4xx — keep localStorage value */ });
})();

// Poll for status updates (SSE caused HTTP/1.1 connection exhaustion — CC fetch would fail)
setInterval(refresh, 4000);

// Wire sidebar navigation
document.querySelectorAll('.sidebar-link').forEach(link => {
  link.addEventListener('click', e => { e.preventDefault(); switchPage(link.dataset.page); });
});
switchPage(currentPage);

window.MinionsRefresh = { refresh };

// ── Refresh-diagnostic footer chip + modal (W-mphejzx100081972) ───────────
// Injected only when the dashboard-refresh-diagnostics feature flag is on
// (engine/features.js → MINIONS_FEATURES.flags). The chip floats bottom-right
// and opens a simple table view of the ring buffer plus a "send to engine"
// button that POSTs the snapshot to /api/diagnostics/refresh. No DOM is
// touched (and no listeners installed beyond the visibilitychange handler
// above, which itself short-circuits when the flag is off) when the flag is
// disabled — production behaviour stays untouched.
(function installRefreshDiagChip() {
  if (!_isRefreshDiagOn()) return;
  try {
    const chip = document.createElement('button');
    chip.id = 'refresh-diag-chip';
    chip.type = 'button';
    chip.textContent = 'diag';
    chip.title = 'Open dashboard refresh diagnostics';
    // Bottom-LEFT (not right) to avoid overlapping the Command Center Send
    // button when the CC drawer is open (the drawer covers the right edge).
    chip.style.cssText = 'position:fixed;left:12px;bottom:12px;z-index:9999;background:var(--surface,#222);color:var(--muted,#aaa);border:1px solid var(--border,#444);border-radius:10px;padding:3px 8px;font-size:var(--text-sm);font-family:inherit;cursor:pointer;opacity:0.75';
    chip.addEventListener('click', _openRefreshDiagModal);
    document.body.appendChild(chip);
  } catch { /* DOM may not be ready in unusual test embeds */ }
})();

function _openRefreshDiagModal() {
  // Remove any prior instance so re-clicks re-render against the latest buffer.
  const prior = document.getElementById('refresh-diag-modal');
  if (prior) prior.remove();
  const entries = window._refreshDiagnostics();
  const overlay = document.createElement('div');
  overlay.id = 'refresh-diag-modal';
  overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:10000;display:flex;align-items:center;justify-content:center;padding:24px';
  overlay.addEventListener('click', function(e) { if (e.target === overlay) overlay.remove(); });
  const panel = document.createElement('div');
  panel.style.cssText = 'background:var(--surface,#1c1c1c);color:var(--text,#ddd);border:1px solid var(--border,#444);border-radius:8px;max-width:1200px;max-height:80vh;width:100%;display:flex;flex-direction:column;overflow:hidden;font-family:inherit;font-size:var(--text-base)';
  const head = document.createElement('div');
  head.style.cssText = 'padding:10px 14px;border-bottom:1px solid var(--border,#444);display:flex;align-items:center;justify-content:space-between;gap:8px';
  const title = document.createElement('div');
  title.style.cssText = 'font-weight:700;color:var(--blue,#58a6ff);font-size:var(--text-lg)';
  title.textContent = 'Refresh diagnostics — ' + entries.length + ' / ' + _DIAG_RING_SIZE + ' entries';
  const actions = document.createElement('div');
  actions.style.cssText = 'display:flex;gap:6px';
  const sendBtn = document.createElement('button');
  sendBtn.type = 'button';
  sendBtn.textContent = 'Send to engine';
  sendBtn.style.cssText = 'background:var(--blue,#1f6feb);color:#fff;border:none;border-radius:4px;padding:4px 10px;font-size:var(--text-base);cursor:pointer';
  sendBtn.addEventListener('click', function() {
    sendBtn.disabled = true;
    sendBtn.textContent = 'Sending…';
    safeFetch('/api/diagnostics/refresh', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ entries: window._refreshDiagnostics() }),
    })
      .then(function(r) { return r.json().catch(function() { return {}; }).then(function(j) { return { ok: r.ok, body: j }; }); })
      .then(function(r) {
        sendBtn.textContent = r.ok ? ('Sent (' + (r.body && r.body.written) + ')') : ('Failed: ' + (r.body && r.body.error || r.ok));
      })
      .catch(function(e) { sendBtn.textContent = 'Error: ' + (e && e.message || e); })
      .finally(function() { setTimeout(function() { sendBtn.disabled = false; sendBtn.textContent = 'Send to engine'; }, 2500); });
  });
  const clearBtn = document.createElement('button');
  clearBtn.type = 'button';
  clearBtn.textContent = 'Clear';
  clearBtn.style.cssText = 'background:none;color:var(--muted,#aaa);border:1px solid var(--border,#444);border-radius:4px;padding:4px 10px;font-size:var(--text-base);cursor:pointer';
  clearBtn.addEventListener('click', function() { window._refreshDiagnosticsClear(); overlay.remove(); });
  const closeBtn = document.createElement('button');
  closeBtn.type = 'button';
  closeBtn.textContent = 'Close';
  closeBtn.style.cssText = 'background:none;color:var(--muted,#aaa);border:1px solid var(--border,#444);border-radius:4px;padding:4px 10px;font-size:var(--text-base);cursor:pointer';
  closeBtn.addEventListener('click', function() { overlay.remove(); });
  actions.appendChild(sendBtn);
  actions.appendChild(clearBtn);
  actions.appendChild(closeBtn);
  head.appendChild(title);
  head.appendChild(actions);
  const body = document.createElement('div');
  body.style.cssText = 'overflow:auto;padding:8px 14px';
  body.appendChild(_renderRefreshDiagTable(entries));
  panel.appendChild(head);
  panel.appendChild(body);
  overlay.appendChild(panel);
  document.body.appendChild(overlay);
}

function _renderRefreshDiagTable(entries) {
  const table = document.createElement('table');
  table.style.cssText = 'width:100%;border-collapse:collapse;font-family:monospace;font-size:var(--text-base)';
  const cols = ['ts', 'kind', 'gap', 'vis', 'status', 'etag↓', 'bytes', 'cacheV', 'wi', 'wiΔ', 'changed', 'render(ms)', 'note'];
  const thead = document.createElement('thead');
  const trh = document.createElement('tr');
  for (const c of cols) {
    const th = document.createElement('th');
    th.textContent = c;
    th.style.cssText = 'text-align:left;padding:3px 6px;border-bottom:1px solid var(--border,#444);color:var(--muted,#aaa);font-weight:600';
    trh.appendChild(th);
  }
  thead.appendChild(trh);
  table.appendChild(thead);
  const tbody = document.createElement('tbody');
  for (let i = entries.length - 1; i >= 0; i--) {
    const e = entries[i];
    const tr = document.createElement('tr');
    const changedKeys = e.changed ? Object.keys(e.changed).filter(function(k) { return e.changed[k]; }) : [];
    const isVis = e.kind === 'visibility';
    const cells = [
      new Date(e.ts).toLocaleTimeString(),
      e.kind || 'refresh',
      e.gap_since_prev_ms != null ? e.gap_since_prev_ms + 'ms' : '',
      e.visibility || '',
      isVis ? (e.hidden_for_ms != null ? 'hidden ' + e.hidden_for_ms + 'ms' : '—') : (e.response_status || ''),
      e.etag_received ? String(e.etag_received).slice(0, 12) + '…' : '',
      e.bytes_received != null ? e.bytes_received : '',
      e.statusCacheVersion != null ? e.statusCacheVersion : '',
      e.workItems_count != null ? e.workItems_count : '',
      e.workItems_changed === true ? '✓' : (e.workItems_changed === false ? '' : ''),
      changedKeys.join(',') || '',
      e.render_duration_ms != null ? e.render_duration_ms : '',
      e.error_message || '',
    ];
    for (const v of cells) {
      const td = document.createElement('td');
      td.textContent = String(v);
      td.style.cssText = 'padding:3px 6px;border-bottom:1px solid rgba(255,255,255,0.05);vertical-align:top';
      tr.appendChild(td);
    }
    if (isVis) tr.style.background = 'rgba(63,185,80,0.05)';
    tbody.appendChild(tr);
  }
  table.appendChild(tbody);
  return table;
}
