// dashboard/js/render-work-items.js — Work item rendering and management extracted from dashboard.html

let allWorkItems = [];
let wiPage = 0;
const WI_PER_PAGE = 20;

// Engine-enrichment field names that live on the /api/status workItems slice
// but are NOT on disk in projects/<name>/work-items.json. The /state/ fetch
// recovers raw disk state — these get overlaid from the most recent
// /api/status snapshot (window._lastWorkItems) so the table still renders the
// PR chip, pending-reason badge, blockedBy hint, etc. The disk fields win
// for everything else, so a status flip from `pending` → `done` lands within
// one /state poll regardless of how stale /api/status got.
const _WI_ENRICHMENT_FIELDS = [
  '_pr', '_prUrl', '_pendingReason', '_skipReason', '_blockedBy',
  '_humanFeedback', '_reopened', '_managedSpawnPartial', '_securityFlag',
  '_artifacts', 'referencesCount', 'acceptanceCriteriaCount',
  // W-mq5xg5e9000nec0e — slim shape marker so the detail/edit modals know
  // to hydrate the full description via GET /api/work-items/<id> rather than
  // pre-filling textareas with the truncated snippet.
  '_descriptionTruncated',
  // W-mq08kuog001110a6 — _preDispatchEval IS persisted to disk by
  // engine/dispatch.js#_persistInvalidWorkItem, but the slim /api/status
  // overlay may drop it on later passes. Carry it across the overlay so
  // the Needs-Attention badge survives /state polls.
  '_preDispatchEval',
];

// W-mq08kuog001110a6 — Needs-Attention badge for stuck pending work items.
//
// A pending WI is "needs attention" when the engine is intentionally refusing
// to dispatch it and a human (or follow-up dispatch) has to unblock it.
// Surfaces existing engine state (_preDispatchEval / _pendingReason) — no
// new fields, no dispatcher behavior change.
//
// Triggers:
//   * _preDispatchEval.valid === false (pre-dispatch acceptance gate rejected)
//   * _pendingReason in { pr_not_found, no_agent, budget_exceeded }
//
// Does NOT trigger for transient reasons that auto-clear:
//   * cooldown / retry_cooldown — wait it out
//   * already_dispatched — engine reconciles on next tick
//   * branch_locked — wait for the holding dispatch
//   * dependency_unmet — auto-clears when upstream dep finishes (engine discovery loop + DEPENDENCY_MET watch)
//
// Returns { kind, short, full } when the item needs attention, else null.
// Pure helper (no DOM / escapeHtml deps) so it can be unit-tested in Node.
function needsAttentionInfo(item) {
  if (!item || item.status !== 'pending') return null;

  if (item._preDispatchEval && item._preDispatchEval.valid === false) {
    var rawReason = String(item._preDispatchEval.reason || '(no reason recorded)');
    var firstLine = rawReason.split(/\r?\n/, 1)[0] || rawReason;
    return {
      kind: 'pre_dispatch_eval',
      short: 'Pre-dispatch evaluation rejected: ' + firstLine,
      full: rawReason,
    };
  }

  var reason = item._pendingReason;
  if (!reason) return null;

  if (reason === 'pr_not_found') {
    var prRef = item.prNumber || item.targetPr || item.pr_id || '?';
    var prMsg = 'Target PR not tracked: PR #' + prRef + '. The PR was likely merged/closed or never linked.';
    return { kind: 'pr_not_found', short: prMsg, full: prMsg };
  }
  if (reason === 'no_agent') {
    var noAgentMsg = 'No agent assigned and routing.md has no match for type=' + (item.type || 'unknown') + '.';
    return { kind: 'no_agent', short: noAgentMsg, full: noAgentMsg };
  }
  if (reason === 'budget_exceeded') {
    var agent = item.agent || item.dispatched_to || '<unknown>';
    var budgetMsg = 'Assigned agent ' + agent + ' is over its maxBudgetUsd cap.';
    return { kind: 'budget_exceeded', short: budgetMsg, full: budgetMsg };
  }
  return null;
}

// Pull each project's work-items.json straight off disk through
// /state/projects/<name>/work-items.json. The endpoint is a static-file
// passthrough with mtime+size ETag — there is no server-side cache in the
// way, so an engine write to `status: 'done'` is visible to the next poll
// (issue #2949 root fix). Enrichment fields are overlaid from a cached
// id→item map sourced from window._lastWorkItems (the last /api/status
// snapshot). All fetches run in parallel; a single project's failure does
// not block the others.
async function fetchWorkItemsFromDisk(projects, cachedById) {
  if (!Array.isArray(projects) || !projects.length) return null;
  const results = await Promise.all(projects.map(async (p) => {
    const name = p && (p.name || p.path);
    if (!name) return [];
    try {
      const r = await fetch('/state/projects/' + encodeURIComponent(name) + '/work-items.json');
      if (!r.ok) return [];
      const arr = await r.json();
      if (!Array.isArray(arr)) return [];
      return arr.map((item) => {
        if (!item || typeof item !== 'object') return null;
        const stamped = Object.assign({}, item, { _source: name });
        const cached = cachedById && cachedById.get(item.id);
        if (cached) {
          for (const k of _WI_ENRICHMENT_FIELDS) {
            if (stamped[k] === undefined && cached[k] !== undefined) {
              stamped[k] = cached[k];
            }
          }
        }
        // referencesCount / acceptanceCriteriaCount don't live on disk;
        // derive from the raw arrays if present so the table icons render
        // even before the cached snapshot catches up.
        if (stamped.referencesCount == null && Array.isArray(stamped.references)) {
          stamped.referencesCount = stamped.references.length;
        }
        if (stamped.acceptanceCriteriaCount == null && Array.isArray(stamped.acceptanceCriteria)) {
          stamped.acceptanceCriteriaCount = stamped.acceptanceCriteria.length;
        }
        return stamped;
      }).filter(Boolean);
    } catch {
      return [];
    }
  }));
  // Flatten + dedupe by id (a project rename mid-flight could double-list).
  const out = [];
  const seen = new Set();
  for (const arr of results) {
    for (const item of arr) {
      if (!item.id || seen.has(item.id)) continue;
      seen.add(item.id);
      out.push(item);
    }
  }
  return out;
}

// Track retry state per work item so loading/success/error survives re-renders
const _wiRetryState = {}; // { [id]: { status: 'pending'|'done'|'error', message?, until? } }
function setWiRetryState(id, state) { _wiRetryState[id] = state; }
function getWiRetryState(id) {
  const s = _wiRetryState[id];
  if (!s) return null;
  if (s.until && Date.now() > s.until) { delete _wiRetryState[id]; return null; }
  return s;
}

function wiRetryBtn(item) {
  const rs = getWiRetryState(item.id);
  if (rs && rs.status === 'pending') {
    return '<span style="font-size:var(--text-xs);padding:1px 6px;color:var(--yellow);border:1px solid rgba(210,153,34,0.35);background:rgba(210,153,34,0.1);border-radius:3px;cursor:wait;margin-left:4px">Retrying\u2026</span>';
  }
  if (rs && rs.status === 'done') {
    return '<span style="font-size:var(--text-xs);padding:1px 6px;color:var(--green);border:1px solid rgba(63,185,80,0.35);background:rgba(63,185,80,0.1);border-radius:3px;margin-left:4px">Requeued</span>';
  }
  if (rs && rs.status === 'error') {
    return '<span style="font-size:var(--text-xs);padding:1px 6px;color:var(--red);border:1px solid rgba(248,81,73,0.35);background:rgba(248,81,73,0.1);border-radius:3px;margin-left:4px;cursor:pointer" title="' + escapeHtml(rs.message || 'Retry failed') + ' — click to try again" onclick="event.stopPropagation();retryWorkItem(\'' + escapeHtml(item.id) + '\',\'' + escapeHtml(item._source || '') + '\')">Retry failed</span>';
  }
  return '<button class="pr-pager-btn" style="font-size:var(--text-xs);padding:1px 6px;color:var(--yellow);border-color:var(--yellow);margin-left:4px" onclick="event.stopPropagation();retryWorkItem(\'' + escapeHtml(item.id) + '\',\'' + escapeHtml(item._source || '') + '\')">Retry</button>';
}

function wiRow(item) {
  const statusBadge = (s) => {
    const cls = s === 'failed' ? 'rejected' : s === 'dispatched' ? 'building' : s === 'pending' || s === 'queued' ? 'active' : s === 'done' ? 'approved' : s === 'decomposed' ? 'approved' : 'draft';
    return '<span class="pr-badge ' + cls + '">' + escapeHtml(s) + '</span>';
  };
  const typeBadge = (t) => '<span class="dispatch-type ' + (t || 'implement') + '">' + escapeHtml(t || 'implement') + '</span>';
  const priBadge = (p) => '<span class="prd-item-priority ' + (p || '') + '">' + escapeHtml(p || 'medium') + '</span>';
  const prLink = item._pr
    ? '<a class="pr-title" href="' + escapeHtml(item._prUrl || '#') + '" target="_blank" style="font-size:var(--text-sm)">' + escapeHtml(item._pr) + '</a>'
    : (item.branchStrategy === 'shared-branch' && item.status === 'done')
      ? '<span style="font-size:var(--text-xs);color:var(--muted)" title="Part of shared branch — aggregate PR created at verify stage">shared branch</span>'
      : '<span style="color:var(--muted)">—</span>';
  // PR follow-up chip (W-mpej3cox00099466) — surfaced when the WI was spun
  // off from a PR comment via meta.pr_followup. Links to the parent PR.
  var prFollowup = item.meta && item.meta.pr_followup;
  var followupChip = '';
  if (prFollowup && prFollowup.parent_pr_url) {
    var prRef = prFollowup.parent_pr_id || prFollowup.parent_pr_url;
    var prNumMatch = String(prRef).match(/(\d+)(?!.*\d)/);
    var prLabel = prNumMatch ? ('PR #' + prNumMatch[1]) : 'parent PR';
    followupChip = ' <a class="pr-badge draft" style="font-size:var(--text-xs);text-decoration:none" target="_blank" rel="noopener" href="' + escapeHtml(prFollowup.parent_pr_url) + '" title="Follow-up dispatched from ' + escapeHtml(prRef) + (prFollowup.parent_comment_author ? ' by ' + escapeHtml(prFollowup.parent_comment_author) : '') + '" onclick="event.stopPropagation()">&#8617; from ' + escapeHtml(prLabel) + '</a>';
  }
  return '<tr data-wi-id="' + escapeHtml(item.id) + '" style="cursor:pointer" onclick="if(shouldIgnoreSelectionClick(event))return;openWorkItemDetail(\'' + escapeHtml(item.id) + '\')">' +
    '<td><span class="pr-id">' + escapeHtml(item.id || '') + '</span></td>' +
    '<td style="max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + escapeHtml((item.title || '').slice(0, 200)) + '">' + escapeHtml(item.title || '') + followupChip + '</td>' +
    '<td><span style="font-size:var(--text-sm);color:var(--muted)">' + escapeHtml(item._source || '') + '</span>' +
      (item.scope === 'fan-out' ? ' <span class="pr-badge ' + (item.status === 'done' || item.status === 'failed' ? 'draft' : 'building') + '" style="font-size:var(--text-xs)">fan-out</span>' : '') + '</td>' +
    '<td>' + typeBadge(item.type) + '</td>' +
    '<td>' + priBadge(item.priority) + '</td>' +
    '<td>' + statusBadge(item.status || 'pending') +
      (item._reopened ? ' <span style="font-size:var(--text-xs);color:var(--purple);margin-left:4px" title="This item was reopened from a previously completed state">reopened</span>' : '') +
      (function() {
        // W-mq08kuog001110a6 — Needs-Attention badge for stuck pending WIs.
        // Click anywhere on the row to open the modal where the full reason
        // is rendered under "Why this is blocked".
        var info = needsAttentionInfo(item);
        if (!info) return '';
        var tooltip = (info.short || '').replace(/\s+/g, ' ').slice(0, 120);
        return ' <span class="pr-badge needs-attention" style="font-size:var(--text-xs);margin-left:4px" title="' + escapeHtml(tooltip) + ' — click row for full reason">&#x26A0; Needs attention</span>';
      })() +
      (item._pendingReason && item.status === 'pending' && item._pendingReason !== 'already_dispatched' ? ' <span style="font-size:var(--text-xs);color:var(--muted);margin-left:4px" title="Pending reason: ' + escapeHtml(item._pendingReason) + '">' + escapeHtml(item._pendingReason.replace(/_/g, ' ')) + '</span>' : '') +
      (item._pendingReason === 'already_dispatched' && item.status === 'pending' ? ' <span style="font-size:var(--text-xs);color:var(--blue);margin-left:4px" title="In dispatch queue, waiting to be assigned">queued</span>' : '') +
      (item._managedSpawnPartial && Array.isArray(item._managedSpawnPartial.failed) && item._managedSpawnPartial.failed.length
        ? ' <span style="font-size:var(--text-xs);color:var(--yellow);margin-left:4px;border:1px solid var(--yellow);padding:0 4px;border-radius:6px" title="managed-spawn: '
            + escapeHtml(((item._managedSpawnPartial.healthy || []).length) + '/' + (((item._managedSpawnPartial.healthy || []).length) + item._managedSpawnPartial.failed.length))
            + ' healthy — failed: '
            + escapeHtml(item._managedSpawnPartial.failed.map(function(f){ return (f && f.name) || '?'; }).join(', '))
            + '. Click row for details.">&#x26A0; managed-spawn: '
            + escapeHtml(((item._managedSpawnPartial.healthy || []).length) + '/' + (((item._managedSpawnPartial.healthy || []).length) + item._managedSpawnPartial.failed.length))
            + ' healthy</span>'
        : '') +
      (item._skipReason && item.status === 'pending' ? ' <span style="font-size:var(--text-xs);color:var(--yellow);margin-left:4px" title="Dispatch blocked: ' + escapeHtml(item._skipReason) + (item._blockedBy ? ' (by ' + escapeHtml(item._blockedBy) + ')' : '') + '">' + escapeHtml(item._skipReason.replace(/_/g, ' ')) + (item._blockedBy ? ' <span style="color:var(--muted)">(' + escapeHtml(item._blockedBy) + ')</span>' : '') + '</span>' : '') +
      (item.status === 'failed' ? ' ' + wiRetryBtn(item) : '') +
    '</td>' +
    '<td>' +
      (item.completedAgents && item.completedAgents.length > 0
        ? '<span class="pr-agent">' + escapeHtml(item.completedAgents.join(', ')) + '</span>'
        : '<span class="pr-agent">' + escapeHtml(item.dispatched_to || item.agent || '—') + '</span>') +
      (item.failReason ? '<span style="display:block;font-size:var(--text-xs);color:var(--red)" title="' + escapeHtml(item.failReason) + '">' + escapeHtml(item.failReason.slice(0, 30)) + '</span>' : '') +
    '</td>' +
    '<td>' + prLink + '</td>' +
    '<td><span class="pr-date">' + escapeHtml((item.created || '').slice(0, 16).replace('T', ' ')) + '</span></td>' +
    '<td style="white-space:nowrap;font-size:var(--text-xs);color:var(--muted)">' +
      (function() {
        // /api/status slims the full arrays into integer counts
        // (dashboard.js:1796-1797: referencesCount / acceptanceCriteriaCount).
        // Read those first; fall back to the array length for callers that
        // still pass the full record (e.g. legacy fixtures, hydrated detail).
        var refCount = (typeof item.referencesCount === 'number')
          ? item.referencesCount
          : (Array.isArray(item.references) ? item.references.length : 0);
        var acCount = (typeof item.acceptanceCriteriaCount === 'number')
          ? item.acceptanceCriteriaCount
          : (Array.isArray(item.acceptanceCriteria) ? item.acceptanceCriteria.length : 0);
        return (refCount ? '<span title="' + refCount + ' reference(s)" style="margin-right:4px">&#x1F517;' + refCount + '</span>' : '') +
               (acCount ? '<span title="' + acCount + ' acceptance criteria">&#x2611;' + acCount + '</span>' : '');
      })() +
    '</td>' +
    '<td style="white-space:nowrap">' +
      ((item.status === 'pending' || item.status === 'failed') ? '<button class="pr-pager-btn" style="font-size:var(--text-xs);padding:1px 6px;color:var(--blue);border-color:var(--blue);margin-right:4px" onclick="event.stopPropagation();editWorkItem(\'' + escapeHtml(item.id) + '\',\'' + escapeHtml(item._source || '') + '\')" title="Edit work item">&#x270E;</button>' : '') +
      ((item.status === 'done' || item.status === 'failed') ? '<button class="pr-pager-btn" style="font-size:var(--text-xs);padding:1px 6px;color:var(--muted);border-color:var(--border);margin-right:4px" onclick="event.stopPropagation();archiveWorkItem(\'' + escapeHtml(item.id) + '\',\'' + escapeHtml(item._source || '') + '\')" title="Archive work item">&#x1F4E6;</button>' : '') +
      ((item.status === 'done' || item.status === 'failed') && !item._humanFeedback ? '<button class="btn-action" style="margin-right:4px" onclick="event.stopPropagation();feedbackWorkItem(\'' + escapeHtml(item.id) + '\',\'' + escapeHtml(item._source || '') + '\')" title="Give feedback">&#x1F44D;&#x1F44E;</button>' : (item._humanFeedback ? '<span style="font-size:var(--text-xs)" title="Feedback given">' + (item._humanFeedback.rating === 'up' ? '&#x1F44D;' : '&#x1F44E;') + '</span> ' : '')) +
      ((item.status === 'pending' || item.status === 'dispatched' || item.status === 'queued' || item.status === 'failed') ? '<button class="pr-pager-btn" style="font-size:var(--text-xs);padding:1px 6px;color:var(--orange);border-color:var(--orange);margin-right:4px" onclick="event.stopPropagation();cancelWorkItem(\'' + escapeHtml(item.id) + '\',\'' + escapeHtml(item._source || '') + '\')" title="Cancel work item and kill agent">&#x1F6AB;</button>' : '') +
      '<button class="btn-destructive" onclick="event.stopPropagation();deleteWorkItem(\'' + escapeHtml(item.id) + '\',\'' + escapeHtml(item._source || '') + '\')" title="Delete work item and kill agent">&#x2715;</button>' +
    '</td>' +
  '</tr>';
}

function renderWorkItems(items, opts) {
  opts = opts || {};
  items = items.filter(function(w) { return !isDeleted('wi:' + w.id); });
  // Sort: active/dispatched first, then by most recent activity
  const statusOrder = { dispatched: 0, pending: 1, queued: 1, failed: 2, done: 3 };
  items.sort((a, b) => {
    const sa = statusOrder[a.status] ?? 2, sb = statusOrder[b.status] ?? 2;
    if (sa !== sb) return sa - sb;
    const ta = a.completedAt || a.dispatched_at || a.created || '';
    const tb = b.completedAt || b.dispatched_at || b.created || '';
    return tb.localeCompare(ta); // most recent first
  });
  allWorkItems = items;
  const el = document.getElementById('work-items-content');
  const countEl = document.getElementById('wi-count');
  if (countEl) countEl.textContent = items.length;
  if (!items.length) {
    el.innerHTML = '<p class="empty">No work items. Add tasks via Command Center above.</p>';
    return;
  }

  const totalPages = Math.ceil(items.length / WI_PER_PAGE);
  const previousPage = wiPage;
  if (wiPage >= totalPages) wiPage = totalPages - 1;
  const start = wiPage * WI_PER_PAGE;
  const pageItems = items.slice(start, start + WI_PER_PAGE);

  let html = '<div class="pr-table-wrap"><table class="pr-table"><thead><tr><th>ID</th><th>Title</th><th>Source</th><th>Type</th><th>Priority</th><th>Status</th><th>Agent</th><th>PR</th><th>Created</th><th></th><th></th></tr></thead><tbody>';
  html += pageItems.map(wiRow).join('');
  html += '</tbody></table></div>';

  if (items.length > WI_PER_PAGE) {
    html += '<div class="pr-pager">' +
      '<span class="pr-page-info">Showing ' + (start+1) + ' to ' + Math.min(start+WI_PER_PAGE, items.length) + ' of ' + items.length + '</span>' +
      '<div class="pr-pager-btns">' +
        '<button class="pr-pager-btn ' + (wiPage === 0 ? 'disabled' : '') + '" onclick="wiPrev()">Prev</button>' +
        '<button class="pr-pager-btn ' + (wiPage >= totalPages-1 ? 'disabled' : '') + '" onclick="wiNext()">Next</button>' +
        '<button class="pr-pager-btn see-all" onclick="openAllWorkItems()">See all ' + items.length + '</button>' +
      '</div>' +
    '</div>';
  }

  const scrollState = opts.preserveScroll === false || previousPage !== wiPage
    ? null
    : captureDashboardScrollState(el, ['.pr-table-wrap']);
  // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escapeHtml() by wiRow() (fields: work item id/title/source/status/agent/PR links/dates/follow-up metadata)
  el.innerHTML = html;
  restoreDashboardScrollState(el, scrollState);
}

async function editWorkItem(id, source) {
  const cached = allWorkItems.find(i => i.id === id);
  if (!cached) return;
  // Hydrate the full record before rendering the form. The bulk
  // /api/work-items list endpoint truncates `description` and replaces
  // `references` / `acceptanceCriteria` arrays with `referencesCount` /
  // `acceptanceCriteriaCount` integers (W-mphejzmj000718bf / W-mq5xg5e9000nec0e
  // — see dashboard.js slimWorkItemForList). Without hydration the textareas
  // would pre-fill with truncated or empty values, and Save would POST those
  // back to handleWorkItemsUpdate, which treats them as defined
  // (dashboard.js:5157, :5164-5165) and silently wipes the stored values:
  // data-loss on every pencil-edit of a pending/failed WI.
  // See PR #2816 review feedback.
  let item = cached;
  const needsHydration = !cached.description ||
    cached._descriptionTruncated === true ||
    (cached.acceptanceCriteriaCount > 0 && !Array.isArray(cached.acceptanceCriteria)) ||
    (cached.referencesCount > 0 && !Array.isArray(cached.references));
  if (needsHydration) {
    try {
      const r = await fetch('/api/work-items/' + encodeURIComponent(id));
      if (!r.ok) throw new Error('HTTP ' + r.status);
      const data = await r.json();
      const full = data && data.item;
      if (!full) throw new Error('no item in response');
      // Cached cross-slice fields (_pr, _source, _artifacts, _pendingReason,
      // etc.) WIN over the on-disk record — they only exist on the engine's
      // in-memory enrichment pass. The full record contributes description,
      // acceptanceCriteria, and references back.
      item = Object.assign({}, full, cached);
      if (full.description != null) item.description = full.description;
      if (Array.isArray(full.acceptanceCriteria)) item.acceptanceCriteria = full.acceptanceCriteria;
      if (Array.isArray(full.references)) item.references = full.references;
    } catch (e) {
      alert('Failed to load work item for editing: ' + (e && e.message ? e.message : e) + '. Refusing to open the edit form with empty fields — saving would wipe the stored values.');
      return;
    }
  }
  const types = ['implement', 'fix', 'review', 'plan', 'verify', 'decompose', 'meeting', 'investigate', 'refactor', 'test', 'explore', 'ask', 'docs', 'setup'];
  const priorities = ['critical', 'high', 'medium', 'low'];
  const agentOpts = (cmdAgents || []).map(a => '<option value="' + escapeHtml(a.id) + '"' + (item.agent === a.id ? ' selected' : '') + '>' + escapeHtml(a.name) + '</option>').join('');
  const typeOpts = types.map(t => '<option value="' + t + '"' + ((item.type || 'implement') === t ? ' selected' : '') + '>' + t + '</option>').join('');
  const priOpts = priorities.map(p => '<option value="' + p + '"' + ((item.priority || 'medium') === p ? ' selected' : '') + '>' + p + '</option>').join('');

  document.getElementById('modal-title').textContent = 'Edit Work Item ' + id;
  document.getElementById('modal-body').style.whiteSpace = 'normal';
  // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escapeHtml() (fields: title, description, references, acceptance criteria, dependencies, agent ids/names, work item id/source)
  document.getElementById('modal-body').innerHTML =
    '<div style="display:flex;flex-direction:column;gap:12px;font-family:inherit">' +
      '<label style="color:var(--text);font-size:var(--text-md)">Title' +
        '<input id="wi-edit-title" value="' + escapeHtml(item.title || '') + '" style="display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md);font-family:inherit">' +
      '</label>' +
      '<label style="color:var(--text);font-size:var(--text-md)">Description' +
        '<textarea id="wi-edit-desc" rows="3" style="display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md);font-family:inherit;resize:vertical">' + escapeHtml(item.description || '') + '</textarea>' +
      '</label>' +
      '<div style="display:flex;gap:12px">' +
        '<label style="color:var(--text);font-size:var(--text-md);flex:1">Type' +
          '<select id="wi-edit-type" style="display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md)">' + typeOpts + '</select>' +
        '</label>' +
        '<label style="color:var(--text);font-size:var(--text-md);flex:1">Priority' +
          '<select id="wi-edit-priority" style="display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md)">' + priOpts + '</select>' +
        '</label>' +
        '<label style="color:var(--text);font-size:var(--text-md);flex:1">Agent' +
          '<select id="wi-edit-agent" style="display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md)"><option value="">Auto</option>' + agentOpts + '</select>' +
        '</label>' +
      '</div>' +
      '<label style="color:var(--text);font-size:var(--text-md)">References (one per line: url | title | type)' +
        '<textarea id="wi-edit-refs" rows="3" style="display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md);font-family:inherit;resize:vertical">' + escapeHtml((item.references || []).map(function(r) { return r.url + ' | ' + (r.title || '') + ' | ' + (r.type || 'link'); }).join('\n')) + '</textarea>' +
      '</label>' +
      '<label style="color:var(--text);font-size:var(--text-md)">Acceptance Criteria (one per line)' +
        '<textarea id="wi-edit-ac" rows="3" style="display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md);font-family:inherit;resize:vertical">' + escapeHtml((item.acceptanceCriteria || []).join('\n')) + '</textarea>' +
      '</label>' +
      '<label style="color:var(--text);font-size:var(--text-md)">Depends On (work-item ids, comma- or newline-separated)' +
        '<textarea id="wi-edit-depends-on" rows="2" placeholder="W-foo, W-bar" style="display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md);font-family:inherit;resize:vertical">' + escapeHtml((Array.isArray(item.depends_on) ? item.depends_on : []).join(', ')) + '</textarea>' +
      '</label>' +
      '<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:8px">' +
        '<button onclick="closeModal()" class="pr-pager-btn" style="padding:6px 16px;font-size:var(--text-md)">Cancel</button>' +
        '<button onclick="submitWorkItemEdit(\'' + escapeHtml(id) + '\',\'' + escapeHtml(source || '') + '\',event)" class="btn-primary-lg">Save</button>' +
      '</div>' +
    '</div>';
  document.getElementById('modal').classList.add('open');
}

async function submitWorkItemEdit(id, source, e) {
  var btn = (e || window.event)?.target; if (btn) { btn.disabled = true; btn.textContent = 'Saving...'; }
  const title = document.getElementById('wi-edit-title').value.trim();
  const description = document.getElementById('wi-edit-desc').value;
  const type = document.getElementById('wi-edit-type').value;
  const priority = document.getElementById('wi-edit-priority').value;
  const agent = document.getElementById('wi-edit-agent').value;
  const refsRaw = document.getElementById('wi-edit-refs')?.value || '';
  const references = refsRaw.split('\n').filter(function(l) { return l.trim(); }).map(function(l) {
    var parts = l.split('|').map(function(s) { return s.trim(); });
    return { url: parts[0] || '', title: parts[1] || parts[0] || '', type: parts[2] || 'link' };
  }).filter(function(r) { return r.url; });
  const acRaw = document.getElementById('wi-edit-ac')?.value || '';
  const acceptanceCriteria = acRaw.split('\n').filter(function(l) { return l.trim(); });
  const dependsRaw = document.getElementById('wi-edit-depends-on')?.value || '';
  const depends_on = dependsRaw.split(/[\n,]/).map(function(s) { return s.trim(); }).filter(Boolean);
  if (!title) { if (btn) { btn.disabled = false; btn.textContent = 'Save'; } alert('Title is required'); return; }
  try { closeModal(); } catch { /* may not be open */ }
  showToast('cmd-toast', 'Work item updated', true);
  try {
    const res = await fetch('/api/work-items/update', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ id, source: source || undefined, title, description, type, priority, agent, references, acceptanceCriteria, depends_on })
    });
    if (res.ok) { refresh(); } else { const d = await res.json().catch(() => ({})); alert('Update failed: ' + (d.error || 'unknown')); editWorkItem(id, source); }
  } catch (e) { alert('Update error: ' + e.message); editWorkItem(id, source); }
}

function _removeWiRow(id) {
  var sel = 'tr[data-wi-id="' + (window.CSS && CSS.escape ? CSS.escape(id) : id) + '"]';
  document.querySelectorAll(sel).forEach(function(r) { r.remove(); });
}

async function deleteWorkItem(id, source) {
  if (!confirm('Delete work item ' + id + '? This will kill any running agent and remove all dispatch history.')) return;
  showToast('cmd-toast', 'Work item deleted', true);
  markDeleted('wi:' + id);
  _removeWiRow(id);
  try {
    const res = await fetch('/api/work-items/delete', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ id, source: source || undefined })
    });
    if (!res.ok) { const d = await res.json().catch(() => ({})); clearDeleted('wi:' + id); showToast('cmd-toast', 'Delete failed: ' + (d.error || 'unknown'), false); refresh(); }
  } catch (e) { clearDeleted('wi:' + id); showToast('cmd-toast', 'Delete error: ' + e.message, false); refresh(); }
}

async function cancelWorkItem(id, source) {
  if (!confirm('Cancel work item ' + id + '? This will kill any running agent and mark it cancelled.')) return;
  showToast('cmd-toast', 'Work item cancelled', true);
  markDeleted('wi:' + id);
  _removeWiRow(id);
  try {
    const res = await fetch('/api/work-items/cancel', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ id, source: source || undefined })
    });
    if (!res.ok) { const d = await res.json().catch(() => ({})); clearDeleted('wi:' + id); showToast('cmd-toast', 'Cancel failed: ' + (d.error || 'unknown'), false); refresh(); }
  } catch (e) { clearDeleted('wi:' + id); showToast('cmd-toast', 'Cancel error: ' + e.message, false); refresh(); }
}

async function archiveWorkItem(id, source) {
  showToast('cmd-toast', 'Archived ' + id, true);
  markDeleted('wi:' + id);
  _removeWiRow(id);
  try {
    const res = await fetch('/api/work-items/archive', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ id, source: source || undefined })
    });
    if (!res.ok) { const d = await res.json().catch(() => ({})); clearDeleted('wi:' + id); showToast('cmd-toast', 'Archive failed: ' + (d.error || 'unknown'), false); refresh(); return; }
  } catch (e) { clearDeleted('wi:' + id); showToast('cmd-toast', 'Archive error: ' + e.message, false); refresh(); }
}

let wiArchiveVisible = false;
async function toggleWorkItemArchive() {
  const el = document.getElementById('work-items-archive');
  wiArchiveVisible = !wiArchiveVisible;
  if (!wiArchiveVisible) { el.style.display = 'none'; return; }
  el.style.display = 'block';
  el.innerHTML = '<p class="empty">Loading archive...</p>';
  try {
    const items = await fetch('/api/work-items/archive').then(r => r.json());
    if (!items.length) { el.innerHTML = '<p class="empty">No archived work items.</p>'; return; }
    // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escapeHtml() (fields: archived work item id/title/type/status/agent)
    el.innerHTML = '<div style="font-size:var(--text-sm);color:var(--muted);margin-bottom:6px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px">Archived (' + items.length + ')</div>' +
      '<div class="pr-table-wrap"><table class="pr-table"><thead><tr><th>ID</th><th>Title</th><th>Type</th><th>Status</th><th>Agent</th><th>Archived</th></tr></thead><tbody>' +
      items.map(function(i) {
        return '<tr style="opacity:0.6">' +
          '<td><span class="pr-id">' + escapeHtml(i.id || '') + '</span></td>' +
          '<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escapeHtml(i.title || '') + '</td>' +
          '<td><span class="dispatch-type ' + (i.type || '') + '">' + escapeHtml(i.type || '') + '</span></td>' +
          '<td style="color:' + (i.status === 'done' ? 'var(--green)' : 'var(--red)') + '">' + escapeHtml(i.status || '') + '</td>' +
          '<td>' + escapeHtml(i.dispatched_to || '—') + '</td>' +
          '<td class="pr-date">' + shortTime(i.archivedAt) + '</td>' +
        '</tr>';
      }).join('') + '</tbody></table></div>';
  } catch (e) { el.innerHTML = '<p class="empty">Failed to load archive.</p>'; }
}

async function retryWorkItem(id, source) {
  // Prevent double-click: if already retrying, ignore
  const existing = getWiRetryState(id);
  if (existing && existing.status === 'pending') return;

  setWiRetryState(id, { status: 'pending' });
  renderWorkItems(allWorkItems);
  try {
    const res = await fetch('/api/work-items/retry', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ id, source: source || undefined })
    });
    if (res.ok) {
      setWiRetryState(id, { status: 'done', until: Date.now() + 8000 });
      showToast('cmd-toast', 'Work item ' + id + ' reset to pending', true);
      wakeEngine();
      refresh();
    } else {
      const d = await res.json().catch(() => ({}));
      const msg = d.error || 'unknown';
      setWiRetryState(id, { status: 'error', message: msg, until: Date.now() + 10000 });
      renderWorkItems(allWorkItems);
    }
  } catch (e) {
    setWiRetryState(id, { status: 'error', message: e.message, until: Date.now() + 10000 });
    renderWorkItems(allWorkItems);
  }
}

function wiPrev() { if (wiPage > 0) { wiPage--; renderWorkItems(allWorkItems, { preserveScroll: false }); } }
function wiNext() { const tp = Math.ceil(allWorkItems.length / WI_PER_PAGE); if (wiPage < tp-1) { wiPage++; renderWorkItems(allWorkItems, { preserveScroll: false }); } }

let _feedbackRating = null;
function feedbackWorkItem(id, source) {
  _feedbackRating = null;
  document.getElementById('modal-title').textContent = 'Feedback on ' + id;
  document.getElementById('modal-body').innerHTML =
    '<div style="display:flex;flex-direction:column;gap:16px">' +
      '<div style="display:flex;gap:16px;justify-content:center">' +
        '<button id="fb-up" onclick="_selectRating(\'up\')" style="font-size:var(--text-display);background:none;border:2px solid var(--border);border-radius:12px;padding:12px 20px;cursor:pointer;transition:all 0.2s">&#x1F44D;</button>' +
        '<button id="fb-down" onclick="_selectRating(\'down\')" style="font-size:var(--text-display);background:none;border:2px solid var(--border);border-radius:12px;padding:12px 20px;cursor:pointer;transition:all 0.2s">&#x1F44E;</button>' +
      '</div>' +
      '<textarea id="feedback-comment" rows="3" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-family:inherit;resize:vertical" placeholder="What was good or needs improvement?"></textarea>' +
      '<div style="display:flex;justify-content:flex-end;gap:8px">' +
        '<button onclick="closeModal()" class="pr-pager-btn">Cancel</button>' +
        '<button id="fb-send" onclick="submitFeedback(\'' + escapeHtml(id) + '\',\'' + escapeHtml(source) + '\')" style="padding:6px 16px;background:var(--surface2);color:var(--muted);border:1px solid var(--border);border-radius:var(--radius-sm);cursor:not-allowed" disabled>Select rating first</button>' +
      '</div>' +
    '</div>';
  document.getElementById('modal').classList.add('open');
}
function _selectRating(r) {
  _feedbackRating = r;
  document.getElementById('fb-up').style.borderColor = r === 'up' ? 'var(--green)' : 'var(--border)';
  document.getElementById('fb-up').style.background = r === 'up' ? 'rgba(63,185,80,0.1)' : 'none';
  document.getElementById('fb-down').style.borderColor = r === 'down' ? 'var(--red)' : 'var(--border)';
  document.getElementById('fb-down').style.background = r === 'down' ? 'rgba(248,81,73,0.1)' : 'none';
  const btn = document.getElementById('fb-send');
  btn.disabled = false;
  btn.style.background = 'var(--blue)';
  btn.style.color = '#fff';
  btn.style.cursor = 'pointer';
  btn.style.borderColor = 'var(--blue)';
  btn.textContent = 'Send Feedback';
}

async function submitFeedback(id, source) {
  const rating = _feedbackRating;
  if (!rating) { alert('Please select a rating first'); return; }
  const comment = document.getElementById('feedback-comment')?.value || '';
  try { closeModal(); } catch { /* may not be open */ }
  showToast('cmd-toast', 'Feedback saved — agents will learn from it', true);
  try {
    const res = await fetch('/api/work-items/feedback', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ id, source, rating, comment })
    });
    if (res.ok) { refresh(); } else { const d = await res.json().catch(() => ({})); alert('Feedback failed: ' + (d.error || 'unknown')); }
  } catch (e) { alert('Error: ' + e.message); }
}

function openCreateWorkItemModal() {
  const typeOpts = ['implement', 'fix', 'explore', 'test', 'review', 'ask', 'plan', 'verify', 'decompose', 'meeting', 'docs', 'setup'].map(t =>
    '<option value="' + t + '"' + (t === 'implement' ? ' selected' : '') + '>' + t + '</option>'
  ).join('');
  const priOpts = ['critical', 'high', 'medium', 'low'].map(p =>
    '<option value="' + p + '"' + (p === 'medium' ? ' selected' : '') + '>' + p + '</option>'
  ).join('');
  const agentOpts = (typeof cmdAgents !== 'undefined' ? cmdAgents : []).map(a =>
    '<option value="' + escapeHtml(a.id) + '">' + escapeHtml(a.name) + '</option>'
  ).join('');
  const projOpts = (typeof cmdProjects !== 'undefined' ? cmdProjects : []).map(p =>
    '<option value="' + escapeHtml(p) + '">' + escapeHtml(p) + '</option>'
  ).join('');
  const inputStyle = 'display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md);font-family:inherit';

  document.getElementById('modal-title').textContent = 'Create Work Item';
  // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escapeHtml() (fields: agent ids/names, project names)
  document.getElementById('modal-body').innerHTML =
    '<div style="display:flex;flex-direction:column;gap:10px">' +
      '<label style="color:var(--text);font-size:var(--text-md)">Title <input id="wi-new-title" style="' + inputStyle + '" placeholder="What needs to be done?"></label>' +
      '<label style="color:var(--text);font-size:var(--text-md)">Description <textarea id="wi-new-desc" rows="3" style="' + inputStyle + ';resize:vertical" placeholder="Detailed description..."></textarea></label>' +
      '<div style="display:flex;gap:8px">' +
        '<label style="flex:1;color:var(--text);font-size:var(--text-md)">Type <select id="wi-new-type" style="' + inputStyle + '">' + typeOpts + '</select></label>' +
        '<label style="flex:1;color:var(--text);font-size:var(--text-md)">Priority <select id="wi-new-priority" style="' + inputStyle + '">' + priOpts + '</select></label>' +
      '</div>' +
      '<div style="display:flex;gap:8px">' +
        '<label style="flex:1;color:var(--text);font-size:var(--text-md)">Agent <select id="wi-new-agent" style="' + inputStyle + '"><option value="">Auto</option>' + agentOpts + '</select></label>' +
        '<label style="flex:1;color:var(--text);font-size:var(--text-md)">Project <select id="wi-new-project" style="' + inputStyle + '"><option value="">Central</option>' + projOpts + '</select></label>' +
      '</div>' +
      '<label style="color:var(--text);font-size:var(--text-md)">Acceptance Criteria <textarea id="wi-new-ac" rows="2" style="' + inputStyle + ';resize:vertical" placeholder="One criterion per line (optional)"></textarea></label>' +
      '<label style="color:var(--text);font-size:var(--text-md)">Depends On <textarea id="wi-new-depends-on" rows="2" style="' + inputStyle + ';resize:vertical" placeholder="W-foo, W-bar — comma- or newline-separated work-item ids (optional)"></textarea></label>' +
      '<label style="color:var(--text);font-size:var(--text-md)">References <textarea id="wi-new-refs" rows="2" style="' + inputStyle + ';resize:vertical" placeholder="url | title | type — one per line (optional)"></textarea></label>' +
      '<label id="wi-new-skippr-row" style="color:var(--text);font-size:var(--text-md);display:flex;gap:8px;align-items:center;cursor:pointer"><input type="checkbox" id="wi-new-skippr"> Skip PR creation (push branch only)</label>' +
      '<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:4px">' +
        '<button onclick="closeModal()" class="pr-pager-btn">Cancel</button>' +
        '<button onclick="_submitCreateWorkItem(event)" class="btn-primary-lg">Create</button>' +
      '</div>' +
    '</div>';
  document.getElementById('modal').classList.add('open');
  // Show skipPr checkbox only for implement/fix types
  const typeSelect = document.getElementById('wi-new-type');
  const skipPrRow = document.getElementById('wi-new-skippr-row');
  function _toggleSkipPr() {
    const v = typeSelect?.value || '';
    if (skipPrRow) skipPrRow.style.display = (v === 'implement' || v === 'fix') ? 'flex' : 'none';
  }
  _toggleSkipPr();
  if (typeSelect) typeSelect.addEventListener('change', _toggleSkipPr);
  setTimeout(() => document.getElementById('wi-new-title')?.focus(), 100);
}

async function _submitCreateWorkItem(e) {
  var btn = (e || window.event)?.target; if (btn) { btn.disabled = true; btn.textContent = 'Creating...'; }
  const title = document.getElementById('wi-new-title')?.value?.trim();
  if (!title) { if (btn) { btn.disabled = false; btn.textContent = 'Create'; } alert('Title is required'); return; }
  const desc = document.getElementById('wi-new-desc')?.value || '';
  const type = document.getElementById('wi-new-type')?.value || 'implement';
  const priority = document.getElementById('wi-new-priority')?.value || 'medium';
  const agent = document.getElementById('wi-new-agent')?.value || '';
  const project = document.getElementById('wi-new-project')?.value || '';
  const acRaw = document.getElementById('wi-new-ac')?.value || '';
  const acceptanceCriteria = acRaw.split('\n').map(l => l.trim()).filter(Boolean);
  const dependsRaw = document.getElementById('wi-new-depends-on')?.value || '';
  const depends_on = dependsRaw.split(/[\n,]/).map(s => s.trim()).filter(Boolean);
  const refsRaw = document.getElementById('wi-new-refs')?.value || '';
  const references = refsRaw.split('\n').filter(l => l.trim()).map(l => {
    const parts = l.split('|').map(s => s.trim());
    return { url: parts[0] || '', title: parts[1] || parts[0] || '', type: parts[2] || 'link' };
  }).filter(r => r.url);

  try {
    const body = { title, description: desc, type, priority };
    if (agent) body.agents = [agent];
    if (project) body.project = project;
    if (acceptanceCriteria.length) body.acceptanceCriteria = acceptanceCriteria;
    if (references.length && references[0].url) body.references = references;
    if (depends_on.length) body.depends_on = depends_on;
    const skipPr = document.getElementById('wi-new-skippr')?.checked || false;
    if (skipPr) body.skipPr = true;

    try { closeModal(); } catch { /* expected */ }
    showToast('cmd-toast', 'Creating work item...', true);
    const res = await fetch('/api/work-items', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body)
    });
    const data = await res.json();
    if (res.ok) {
      wakeEngine();
      refresh();
      showToast('cmd-toast', 'Work item ' + (data.id || '') + ' created', true);
    } else {
      alert('Failed: ' + (data.error || 'unknown'));
      openCreateWorkItemModal();
    }
  } catch (e) { alert('Error: ' + e.message); openCreateWorkItemModal(); }
}

function _wiRenderDetail(item) {
  const field = (label, value) => value ? '<div style="margin-bottom:8px"><span style="color:var(--muted);font-size:var(--text-sm);text-transform:uppercase;letter-spacing:0.5px">' + label + '</span><div style="margin-top:2px">' + value + '</div></div>' : '';
  const badge = (cls, text) => '<span class="pr-badge ' + cls + '">' + escapeHtml(text) + '</span>';
  const statusCls = item.status === 'failed' ? 'rejected' : item.status === 'dispatched' ? 'building' : item.status === 'done' ? 'approved' : 'active';

  let html = '<div style="display:flex;flex-direction:column;gap:4px;font-size:var(--text-lg)">';
  html += '<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px">' +
    badge(statusCls, item.status || 'pending') +
    (item._reopened ? ' <span style="font-size:var(--text-xs);color:var(--purple);margin-left:4px" title="This item was reopened from a previously completed state">reopened</span>' : '') + ' ' +
    '<span class="dispatch-type ' + (item.type || 'implement') + '">' + escapeHtml(item.type || 'implement') + '</span>' +
    '<span class="prd-item-priority ' + (item.priority || '') + '">' + escapeHtml(item.priority || 'medium') + '</span>' +
    (function() {
      // W-mq08kuog001110a6 — Needs-Attention chip in the detail modal header.
      var info = needsAttentionInfo(item);
      if (!info) return '';
      return ' <span class="pr-badge needs-attention" style="font-size:var(--text-xs);margin-left:4px" title="See &quot;Why this is blocked&quot; below">&#x26A0; Needs attention</span>';
    })() +
    '</div>';
  // W-mq08kuog001110a6 — "Why this is blocked" section, rendered FIRST so the
  // human eye lands on the unblocking reason before scrolling through agent,
  // source, dates, etc. _preDispatchEval reasons are evaluator prose and can
  // be multi-line — render verbatim in a <pre> to preserve newlines.
  (function() {
    var info = needsAttentionInfo(item);
    if (!info) return;
    html += field(
      'Why this is blocked',
      '<div style="border-left:3px solid var(--yellow);padding:8px 10px;background:rgba(210,153,34,0.08);border-radius:var(--radius-sm)">' +
        '<div style="font-size:var(--text-sm);color:var(--muted);margin-bottom:4px">reason: <code>' + escapeHtml(info.kind) + '</code></div>' +
        '<pre style="margin:0;font-size:var(--text-sm);white-space:pre-wrap;word-break:break-word;font-family:var(--font-mono, monospace)">' + escapeHtml(info.full) + '</pre>' +
      '</div>'
    );
  })();
  // Description: rendered from the FULL record once it's hydrated. The /api/status
  // slice drops `description` to keep the SPA payload <500KB; on first paint we
  // either render the title as a placeholder or, when description is already
  // present (legacy/full record), render it directly. The hydration pass below
  // re-renders this section in place once the fetch lands.
  var _descHtml;
  if (item.description) {
    _descHtml = renderMd(item.description);
  } else if (item._descriptionLoading) {
    _descHtml = '<span style="color:var(--muted)">Loading description…</span>';
  } else {
    _descHtml = escapeHtml(item.title || '—');
  }
  html += field('Description', '<div id="wi-detail-desc" style="font-size:var(--text-md);max-height:320px;overflow-y:auto;padding:8px 10px;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm)">' + _descHtml + '</div>');
  html += field('Agent', escapeHtml(item.dispatched_to || item.agent || 'Auto'));
  html += field('Source', escapeHtml(item._source || 'central'));
  if (item.created) html += field('Created', escapeHtml(formatLocalDateTime(item.created)));
  if (item.dispatched_at) html += field('Dispatched', escapeHtml(formatLocalDateTime(item.dispatched_at)) + ' to ' + escapeHtml(item.dispatched_to || '?'));
  if (item.completedAt) html += field('Completed', escapeHtml(formatLocalDateTime(item.completedAt)));
  if (item.failReason) html += field('Failure Reason', '<span style="color:var(--red)">' + escapeHtml(item.failReason) + '</span>');
  if (item._pendingReason && item.status === 'pending') html += field('Pending Reason', item._pendingReason === 'already_dispatched' ? 'Queued — waiting for available agent slot' : escapeHtml(item._pendingReason.replace(/_/g, ' ')));
  if (item._managedSpawnPartial && Array.isArray(item._managedSpawnPartial.failed) && item._managedSpawnPartial.failed.length) {
    var _msp = item._managedSpawnPartial;
    var _mspHealthy = Array.isArray(_msp.healthy) ? _msp.healthy : [];
    var _mspTotal = _mspHealthy.length + _msp.failed.length;
    var _mspBody = '<div style="color:var(--yellow);font-size:var(--text-base);margin-bottom:6px">'
      + escapeHtml(_mspHealthy.length + '/' + _mspTotal) + ' specs healthy. Failed: '
      + escapeHtml(_msp.failed.map(function(f){ return (f && f.name) || '?'; }).join(', '))
      + (_msp.evaluated_at ? ' <span style="color:var(--muted)">(evaluated ' + escapeHtml(_msp.evaluated_at) + ')</span>' : '')
      + '</div>';
    _mspBody += _msp.failed.map(function(f) {
      var name = escapeHtml((f && f.name) || '?');
      var reason = escapeHtml((f && f.reason) || 'unknown');
      var tail = (f && f.log_tail) || '';
      return '<details style="margin-bottom:6px"><summary style="cursor:pointer;font-size:var(--text-base)"><strong>' + name + '</strong> — ' + reason + '</summary>'
        + '<pre style="font-size:var(--text-sm);max-height:240px;overflow:auto;padding:6px;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm);margin:4px 0 0 0">'
        + escapeHtml(tail || '(no log tail captured)')
        + '</pre></details>';
    }).join('');
    html += field('Managed-spawn partial failure', _mspBody);
  }
  if (item._skipReason && item.status === 'pending') html += field('Dispatch Blocked', '<span style="color:var(--yellow)">' + escapeHtml(item._skipReason.replace(/_/g, ' ')) + '</span>' + (item._blockedBy ? ' — blocked by <strong>' + escapeHtml(item._blockedBy) + '</strong>' : ''));
  // Defensive: CC dispatches can land here with these fields as strings
  // (e.g. acceptanceCriteria: "fix the login bug"). Coerce to arrays so
  // .map() doesn't throw and crash the modal.
  var deps = Array.isArray(item.depends_on) ? item.depends_on : [];
  if (deps.length) html += field('Depends On', deps.map(d => '<code>' + escapeHtml(d) + '</code>').join(', '));
  var ac = Array.isArray(item.acceptanceCriteria)
    ? item.acceptanceCriteria
    : (typeof item.acceptanceCriteria === 'string' && item.acceptanceCriteria.trim()
        ? item.acceptanceCriteria.split(/\r?\n/).map(s => s.trim()).filter(Boolean)
        : []);
  if (ac.length) {
    html += field('Acceptance Criteria', '<ul style="margin:0;padding-left:20px">' + ac.map(c => '<li>' + escapeHtml(c) + '</li>').join('') + '</ul>');
  } else if (item.acceptanceCriteriaCount > 0) {
    // Count-only marker from the slim /api/status slice; full list arrives
    // on the next hydration pass (or is already loading).
    html += field('Acceptance Criteria', '<span style="color:var(--muted)">' + item.acceptanceCriteriaCount + ' criteria — loading…</span>');
  }
  var refs = Array.isArray(item.references) ? item.references.filter(r => r && typeof r === 'object' && r.url) : [];
  if (refs.length) {
    html += field('References', refs.map(r => '<a href="' + escapeHtml(r.url) + '" target="_blank" style="color:var(--blue)">' + escapeHtml(r.title || r.url) + '</a>' + (r.type ? ' <span style="color:var(--muted);font-size:var(--text-sm)">(' + escapeHtml(r.type) + ')</span>' : '')).join('<br>'));
  } else if (item.referencesCount > 0) {
    html += field('References', '<span style="color:var(--muted)">' + item.referencesCount + ' reference(s) — loading…</span>');
  }
  if (item._humanFeedback) html += field('Human Feedback', (item._humanFeedback.rating === 'up' ? '👍' : '👎') + (item._humanFeedback.comment ? ' — ' + escapeHtml(item._humanFeedback.comment) : ''));
  if (item._pr) html += field('Pull Request', '<a href="' + escapeHtml(item._prUrl || '#') + '" target="_blank" style="color:var(--blue)">' + escapeHtml(item._pr) + '</a>');

  // Artifacts — output log, branch, skills, etc.
  var arts = item._artifacts || {};
  var artPills = '';
  var pillStyle = 'display:inline-flex;align-items:center;gap:3px;padding:2px 8px;border-radius:10px;font-size:var(--text-sm);cursor:pointer;background:var(--surface2);border:1px solid var(--border);color:var(--text)';
  // Output log pill removed — raw stream-json output is not human-readable
  var artBackFn = "pushModalBack(function(){openWorkItemDetail('" + escapeHtml(item.id) + "')});";
  if (arts.branch) artPills += '<span style="' + pillStyle + ';cursor:default">🌿 ' + escapeHtml(arts.branch) + '</span> ';
  if (arts.plan) artPills += '<span onclick="' + artBackFn + 'planView(\'' + escapeHtml(arts.plan) + '\')" style="' + pillStyle + '">📋 Plan</span> ';
  if (arts.prd) artPills += '<span onclick="' + artBackFn + 'planView(\'' + escapeHtml(arts.prd) + '\')" style="' + pillStyle + '">📄 PRD</span> ';
  if (arts.sourcePlan) artPills += '<span onclick="' + artBackFn + 'planView(\'' + escapeHtml(arts.sourcePlan) + '\')" style="' + pillStyle + '">📋 Source Plan</span> ';
  if (arts.notes && arts.notes.length > 0) {
    arts.notes.forEach(function(n) {
      var noteFile = (n && typeof n === 'object') ? (n.file || n) : String(n || '');
      if (noteFile.startsWith('kb:')) {
        var kbParts = noteFile.slice(3).split('/');
        var kbCat = kbParts[0];
        var kbFile = kbParts.slice(1).join('/');
        var kbLabel = kbFile.replace(/\.md$/, '').slice(0, 30);
        artPills += '<span onclick="' + artBackFn + 'kbOpenItem(\'' + escapeHtml(kbCat) + '\',\'' + escapeHtml(kbFile) + '\')" style="' + pillStyle + '">📚 ' + escapeHtml(kbLabel) + '</span> ';
      } else if (noteFile.startsWith('archive:')) {
        var archLabel = noteFile.slice(8).replace(/\.md$/, '').replace(/^\d{4}-\d{2}-\d{2}-/, '').slice(0, 30);
        artPills += '<span onclick="' + artBackFn + 'openInboxNote(\'' + escapeHtml(noteFile.slice(8)) + '\')" style="' + pillStyle + ';opacity:0.7">📄 ' + escapeHtml(archLabel) + ' <span style="font-size:var(--text-xs)">(archived)</span></span> ';
      } else {
        var noteLabel = noteFile.replace(/\.md$/, '').slice(0, 30);
        artPills += '<span onclick="' + artBackFn + 'openInboxNote(\'' + escapeHtml(noteFile) + '\')" style="' + pillStyle + '">📝 ' + escapeHtml(noteLabel) + '</span> ';
      }
    });
  }
  if (artPills) html += field('Artifacts', '<div style="display:flex;flex-wrap:wrap;gap:4px">' + artPills + '</div>');

  if (item._totalCostUsd != null) html += field('Cumulative Cost', '$' + Number(item._totalCostUsd).toFixed(4));
  if (item._totalInputTokens) html += field('Total Input Tokens', Number(item._totalInputTokens).toLocaleString());
  if (item._totalOutputTokens) html += field('Total Output Tokens', Number(item._totalOutputTokens).toLocaleString());
  html += '</div>';
  return html;
}

function openWorkItemDetail(id) {
  if (window.getSelection && window.getSelection().toString().length > 0) return;
  const cached = allWorkItems.find(i => i.id === id);
  if (!cached) return;

  // Render the modal immediately from the cached (slim) record so the click
  // feels instant. The bulk /api/work-items list is the source of badges,
  // status, PR link, artifacts, etc. — everything except the heavy free-text
  // fields (description hard-capped, full acceptanceCriteria, full references)
  // — see W-mphejzmj000718bf / W-mq5xg5e9000nec0e. We then hydrate the
  // missing/truncated fields from GET /api/work-items/<id> and re-render in
  // place.
  const needsHydration = !cached.description ||
    cached._descriptionTruncated === true ||
    (cached.acceptanceCriteriaCount > 0 && !Array.isArray(cached.acceptanceCriteria)) ||
    (cached.referencesCount > 0 && !Array.isArray(cached.references));

  const initial = needsHydration ? Object.assign({}, cached, { _descriptionLoading: true }) : cached;
  document.getElementById('modal-title').textContent = initial.title || initial.id;
  // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escapeHtml() or renderMd() by _wiRenderDetail() (fields: title, description, agent, source, reasons, references, artifacts, PR links)
  document.getElementById('modal-body').innerHTML = _wiRenderDetail(initial);
  document.getElementById('modal-body').style.fontFamily = "'Segoe UI', system-ui, sans-serif";
  document.getElementById('modal-body').style.whiteSpace = 'normal';
  document.getElementById('modal').classList.add('open');

  if (!needsHydration) return;

  fetch('/api/work-items/' + encodeURIComponent(id))
    .then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
    .then(function(data) {
      // Guard against modal navigation away from this WI during the fetch.
      var title = document.getElementById('modal-title');
      if (!title || title.textContent !== (initial.title || initial.id)) return;
      var full = data && data.item;
      if (!full) return;
      // Merge: cached cross-slice fields (_pr, _artifacts, etc.) WIN over
      // the on-disk record so we don't lose engine enrichment that lives
      // only on the in-memory pass. The full record contributes description,
      // acceptanceCriteria, and references back to the rendered shape.
      var merged = Object.assign({}, full, cached);
      merged.description = full.description || cached.description || '';
      if (Array.isArray(full.acceptanceCriteria)) merged.acceptanceCriteria = full.acceptanceCriteria;
      if (Array.isArray(full.references)) merged.references = full.references;
      // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escapeHtml() or renderMd() by _wiRenderDetail() (fields: title, description, agent, source, reasons, references, artifacts, PR links)
      document.getElementById('modal-body').innerHTML = _wiRenderDetail(merged);
    })
    .catch(function() {
      var desc = document.getElementById('wi-detail-desc');
      if (desc) desc.innerHTML = '<span style="color:var(--red)">Failed to load full record.</span>';
    });
}

function openAllWorkItems() {
  document.getElementById('modal-title').textContent = 'All Work Items (' + allWorkItems.length + ')';
  const html = '<div class="pr-table-wrap"><table class="pr-table"><thead><tr><th>ID</th><th>Title</th><th>Source</th><th>Type</th><th>Priority</th><th>Status</th><th>Agent</th><th>PR</th><th>Created</th><th></th><th></th></tr></thead><tbody>' +
    allWorkItems.map(wiRow).join('') + '</tbody></table></div>';
  // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escapeHtml() by wiRow() (fields: work item id/title/source/status/agent/PR links/dates/follow-up metadata)
  document.getElementById('modal-body').innerHTML = html;
  document.getElementById('modal-body').style.fontFamily = "'Segoe UI', system-ui, sans-serif";
  document.getElementById('modal-body').style.whiteSpace = 'normal';
  document.getElementById('modal').classList.add('open');
}

function viewAgentOutput(logPath) {
  document.getElementById('modal-title').textContent = 'Agent Output';
  document.getElementById('modal-body').innerHTML = '<p style="color:var(--muted)">Loading...</p>';
  document.getElementById('modal-body').style.fontFamily = 'Consolas, monospace';
  document.getElementById('modal-body').style.whiteSpace = 'normal';
  document.getElementById('modal').classList.add('open');
  fetch('/api/agent-output?file=' + encodeURIComponent(logPath))
    .then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.text(); })
    .then(function(content) {
      // eslint-disable-next-line no-unsanitized/property -- reason: renderAgentOutput() escapes all user-controlled fields before assembling HTML (see dashboard/js/render-utils.js)
      document.getElementById('modal-body').innerHTML = renderAgentOutput(content);
    })
    .catch(function() {
      document.getElementById('modal-body').innerHTML = '<p style="color:var(--red)">Failed to load output.</p>';
    });
}

function openInboxNote(filename) {
  var idx = (inboxData || []).findIndex(function(item) { return item.name === filename; });
  if (idx >= 0) { openModal(idx); return; }
  // Not in the live inbox — it's an archived note (the common case for a
  // completed work item's note pill, which the rewriter re-encodes as an
  // 'archive:<name>' token). Fetch it from notes/archive/ and show it in the
  // modal instead of bouncing to the inbox page. Falls back to the inbox page
  // only if the file is genuinely gone (e.g. merged into notes.md).
  fetch('/state/notes/archive/' + encodeURIComponent(filename))
    .then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.text(); })
    .then(function(content) {
      var titleEl = document.getElementById('modal-title');
      var bodyEl = document.getElementById('modal-body');
      if (!titleEl || !bodyEl) return;
      titleEl.textContent = filename + ' (archived)';
      // eslint-disable-next-line no-unsanitized/property -- reason: renderMd() escapes note content before assembling HTML; filename is set via textContent
      bodyEl.innerHTML = '<div style="font-size:var(--text-md);line-height:1.7;color:var(--muted)">' + renderMd(content) + '</div>';
      document.getElementById('modal').classList.add('open');
    })
    .catch(function() {
      closeModal();
      switchPage('inbox');
    });
}

if (typeof window !== 'undefined') {
  window.MinionsWork = { wiRow, renderWorkItems, editWorkItem, submitWorkItemEdit, deleteWorkItem, archiveWorkItem, toggleWorkItemArchive, retryWorkItem, wiPrev, wiNext, feedbackWorkItem, submitFeedback, openCreateWorkItemModal, openWorkItemDetail, openAllWorkItems, viewAgentOutput, openInboxNote, needsAttentionInfo };
}
// exported for testing — pure helpers safe to require under Node (no DOM deps).
if (typeof module !== 'undefined' && module.exports) {
  module.exports = { needsAttentionInfo };
}
