import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getDeployFolderStatus, syncJobToDeployFolder } from './deployFolderSync';
import { downloadRunLogZip, formatRelOffset } from './exportRunLog';
import { downloadPromptMd } from './downloadPrompt';
import {
  collectReportEvidence,
  SEED_SOLUTION_STRATA,
} from '../../src/tier1/reportEvidence';
import { isSubstrateEvent, jobEventToneCss, t1EventToneCss } from '../../src/tier1/eventTone';
import {
  emptyPipelineLogLabel,
  formatCheckpointHeader,
  isCheckpointedUnfinalized,
  RUN_RECORD_MISSING,
} from '../../src/tier1/persistSafe';
import { AdminView } from './AdminView';
import { InstallStatsPanel } from './InstallStatsPanel';
import { MetricsGridTable } from './MetricsGridTable';
import { RunReport } from './RunReport';
import {
  shouldShowStaleBundleBanner,
  parseWebVersion,
  STALE_BUNDLE_BANNER,
  STALE_BUNDLE_POLL_MS,
} from './staleBundle';
import {
  STAGE_LABELS,
  canonicalizePhase,
  formatStageElapsed,
  stageProgress,
  type StagePhase,
} from './stagePipeline';
import { WEB_BUILD_ID } from './generated/webVersion';
import {
  CyclesPerFeatureChart,
  StageTokensChart,
  TokenBurnTimeline,
} from './RunCharts';
import {
  BASE_TAB_TITLE,
  cyclesFromMetrics,
  displayPipelineMessage,
  elapsedMinutesSince,
  formatHms,
  formatRunningTabTitle,
  SPIN_FAVICON_FRAMES,
} from './tier1Metrics';
import { formatPageNotifyLog, formatSwNotifyLog, type PageNotifyResult } from './notifyLog';
import {
  aggregateProjectStats,
  buildMetricsGrid,
  buildRunMetricsGrid,
  classifyRunOutcome,
  runSnapshot,
  type RunOutcome,
  type RunRecordLike,
} from '../../src/tier1/statsAggregate';
import { VERSION as APP_VERSION } from '../../src/version';

// --- API types (mirrors src/core/types.ts, trimmed to what the UI renders) --

interface FeatureNode {
  id: string;
  name: string;
  description: string;
  deps: string[];
  files: string[];
  testFile: string;
  edgeCases: string[];
  exports: { file: string; symbol: string }[];
}

interface IRCase { featureId: string; name: string; kind: string }

interface Escalation {
  featureId: string;
  featureName: string;
  bugClass: string;
  attempts: number;
  findings: { bugClass: string; file: string; line: number | null; message: string; testName?: string }[];
  frozenContractFindings: boolean;
  suspectedContractSkew: { withFeatures: string[]; evidence: string } | null;
  ledgerTrail: string[];
  recommendation: string;
}

interface LlmCall {
  op: 'encode' | 'ir' | 'build' | 'repair' | 'fixStub';
  featureId: string | null;
  model: string;
  ms: number;
  inputTokens: number;
  outputTokens: number;
  cacheReadTokens: number;
  cacheWriteTokens: number;
  role?: 'operator' | 'supervisor' | 'pool';
  poolIdx?: number;
  provider?: string;
}

interface PromptAssessment {
  sufficient: boolean;
  featuresIdentified: string[];
  deficiencies: { aspect: string; issue: string; question: string }[];
  suggestedRewrite: string;
  verdicts?: { name: string; scope: string; clear: boolean; assertion: string }[];
}

interface Job {
  id: string;
  title: string;
  prompt: string;
  phase: string;
  createdAt: string;
  sortIndex?: number;
  starred?: boolean;
  pinned?: boolean;
  scaffoldWarning?: string | null;
  autopilot?: boolean;
  workspaceDir: string;
  promptAssessment: PromptAssessment | null;
  irEscalations: { featureId: string; featureName: string; diagnostics: string[]; gate?: 'spec' | 'stub' | 'ir'; collateralOf?: string }[];
  spec: { title: string; features: FeatureNode[] } | null;
  manifest: { cases: IRCase[]; frozenAt: string | null } | null;
  buildResults: { featureId: string; passed: number; failed: number; failures: { name: string; message: string }[] }[];
  report: {
    totalTests: number; passed: number; failed: number;
    episodes: { episode: number; featureId: string; bugClass: string; attempt: number; outcome: string; parentRecheck: string; summary: string }[];
    filesTouchedByRha: string[];
    ledgerRows: number; commits: number;
    escalations: Escalation[];
  } | null;
  error: string | null;
  llmUsage: LlmCall[];
}

const NOTIFY_KEY = 'rha-notify-jobs';
const NOTIFY_PHASES = new Set([
  'complete', 'failed', 'interrupted', 'insufficient-input',
  'awaiting-ir-approval', 'awaiting-build-approval',
]);

function readNotifyMap(): Record<string, true> {
  try {
    const raw = localStorage.getItem(NOTIFY_KEY);
    return raw ? JSON.parse(raw) as Record<string, true> : {};
  } catch { return {}; }
}
function isNotifyEnabled(id: string): boolean {
  return !!readNotifyMap()[id];
}
function setNotifyEnabled(id: string, on: boolean): void {
  const m = readNotifyMap();
  if (on) m[id] = true; else delete m[id];
  localStorage.setItem(NOTIFY_KEY, JSON.stringify(m));
}
function notifyBody(j: Job): string {
  if (j.phase === 'complete' && j.report) {
    return `complete — ${j.report.passed}/${j.report.totalTests} passing`;
  }
  if (j.phase === 'failed') return `failed${j.error ? ` — ${j.error.slice(0, 120)}` : ''}`;
  if (j.phase === 'insufficient-input') {
    const n = j.promptAssessment?.deficiencies.length ?? 0;
    return `insufficient-input — ${n} deficienc${n === 1 ? 'y' : 'ies'}`;
  }
  if (j.phase === 'awaiting-ir-approval') return 'awaiting IR approval';
  if (j.phase === 'awaiting-build-approval') return 'awaiting build approval';
  return j.phase;
}

/**
 * SET 29c — the single page-path Notification attempt, always logged. The
 * hidden-tab gate (title says "only while this tab is in the background")
 * means a visible tab skipping is by design — logged as `skipped(tab
 * visible)`, not silence. `bypassHiddenGate` is used by gate/terminal
 * phases (which notify even while focused) and by the Test button.
 */
function attemptPageNotify(
  title: string,
  options: NotificationOptions,
  opts: { bypassHiddenGate?: boolean } = {},
): { log: string; result: PageNotifyResult } {
  const hidden = document.hidden;
  if (typeof Notification === 'undefined') {
    const result: PageNotifyResult = { kind: 'skipped', reason: 'unsupported' };
    return { log: formatPageNotifyLog('unsupported', hidden, result), result };
  }
  const perm = Notification.permission;
  if (perm !== 'granted') {
    const result: PageNotifyResult = { kind: 'skipped', reason: `permission ${perm}` };
    return { log: formatPageNotifyLog(perm, hidden, result), result };
  }
  if (!opts.bypassHiddenGate && !hidden) {
    const result: PageNotifyResult = { kind: 'skipped', reason: 'tab visible' };
    return { log: formatPageNotifyLog(perm, hidden, result), result };
  }
  try {
    new Notification(title, options);
    const result: PageNotifyResult = { kind: 'fired' };
    return { log: formatPageNotifyLog(perm, hidden, result), result };
  } catch (e) {
    const result: PageNotifyResult = { kind: 'error', message: e instanceof Error ? e.message : String(e) };
    return { log: formatPageNotifyLog(perm, hidden, result), result };
  }
}

/** Display title used in the list (title if set, else first line / 40 chars of prompt). */
function jobDisplayTitle(j: Job): string {
  if (j.title.trim()) return j.title.trim();
  const first = j.prompt.split(/\r?\n/).find(l => l.trim()) ?? j.prompt;
  return first.trim().slice(0, 40);
}

// Opus 4.8 list pricing per token; only applied when the call's model matches.
const OPUS48 = { in: 5 / 1e6, out: 25 / 1e6, cacheRead: 0.5 / 1e6, cacheWrite: 6.25 / 1e6 };

function usageTotals(calls: LlmCall[]) {
  const t = { calls: calls.length, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, priced: 0 };
  for (const c of calls) {
    t.input += c.inputTokens; t.output += c.outputTokens;
    t.cacheRead += c.cacheReadTokens; t.cacheWrite += c.cacheWriteTokens;
    if (c.model.startsWith('claude-opus-4-8')) {
      t.priced++;
      t.cost += c.inputTokens * OPUS48.in + c.outputTokens * OPUS48.out
        + c.cacheReadTokens * OPUS48.cacheRead + c.cacheWriteTokens * OPUS48.cacheWrite;
    }
  }
  return t;
}

function fmtTokens(n: number): string {
  return n >= 1000 ? (n / 1000).toFixed(n >= 100_000 ? 0 : 1) + 'k' : String(n);
}

function fmtEta(ms: number): string | undefined {
  const s = Math.round(ms / 1000);
  if (s < 15) return undefined;
  if (s < 60) return `~${s}s`;
  return `~${Math.ceil(s / 60)}m`;
}

function computeProgress(
  job: Job,
  events: JobEvent[],
): { overall: number; byFeature: Map<string, number>; eta?: string } {
  const features = job.spec?.features ?? [];
  const N = features.length;
  const phase = job.phase;

  if (phase === 'complete' || phase === 'failed' || phase === 'insufficient-input') {
    return { overall: 100, byFeature: new Map(features.map(f => [f.id, 100])) };
  }
  if (N === 0 || phase === 'created' || phase === 'encoding') {
    return { overall: 2, byFeature: new Map() };
  }

  const irDone = new Set(
    events.filter(e => e.kind === 'ir-generated').map(e => e.data.featureId as string),
  );
  const builtDone = new Set(
    events.filter(e => e.kind === 'auth-result').map(e => e.data.featureId as string),
  );
  const rhaHealed = new Set(
    events
      .filter(e => e.kind === 'rha-repair' && e.data.outcome === 'healed')
      .map(e => e.data.featureId as string),
  );

  // Per-feature pipeline stages: 0 → 33 (IR) → 67 (built) → 100 (healed/done)
  const byFeature = new Map<string, number>();
  for (const f of features) {
    let fp = 0;
    if (irDone.has(f.id)) fp = 33;
    if (builtDone.has(f.id)) fp = 67;
    if (rhaHealed.has(f.id)) fp = 100;
    byFeature.set(f.id, fp);
  }

  // Overall: encode 0–5 %, IR 5–35 %, gate 35–37 %, build 37–75 %, heal 75–95 %
  let overall = 0;
  if (phase === 'encoding') {
    overall = events.some(e => e.kind === 'stubs-ready') ? 4 : 2;
  } else if (phase === 'generating-ir') {
    overall = 5 + (irDone.size / N) * 30;
  } else if (phase === 'awaiting-ir-approval') {
    overall = 35;
  } else if (phase === 'building') {
    overall = 37 + (builtDone.size / N) * 38;
  } else if (phase === 'awaiting-build-approval') {
    overall = 75;
  } else if (phase === 'healing') {
    const failingN = Math.max((job.buildResults ?? []).filter(r => r.failed > 0).length, 1);
    const sweepCount = events.filter(e => e.kind === 'rha-sweep').length;
    const healFrac = Math.max(rhaHealed.size / failingN, Math.min(sweepCount / 4, 0.85));
    overall = 75 + Math.min(healFrac, 0.95) * 20;
  }

  // Self-correcting ETA: once first features finish a phase, extrapolate the rest
  let eta: string | undefined;
  const now = Date.now();
  if (phase === 'generating-ir' && irDone.size > 0 && irDone.size < N) {
    const startEv = [...events].reverse().find(e => e.kind === 'phase' && e.data.phase === 'generating-ir');
    if (startEv) {
      const avg = (now - new Date(startEv.ts).getTime()) / irDone.size;
      eta = fmtEta(avg * (N - irDone.size));
    }
  } else if (phase === 'building' && builtDone.size > 0 && builtDone.size < N) {
    const startEv = [...events].reverse().find(e => e.kind === 'phase' && e.data.phase === 'building');
    if (startEv) {
      const avg = (now - new Date(startEv.ts).getTime()) / builtDone.size;
      eta = fmtEta(avg * (N - builtDone.size));
    }
  }

  return { overall, byFeature, eta };
}

function OverallBar({ pct, eta, phase }: { pct: number; eta?: string; phase: string }) {
  const clamped = Math.max(0, Math.min(100, pct));
  const fillCls = phase === 'complete' ? 'progressfill done'
    : phase === 'failed' ? 'progressfill fail'
    : phase === 'insufficient-input' ? 'progressfill gate'
    : 'progressfill';
  return (
    <div className="progresswrap">
      <div className="progresstrack">
        <div className={fillCls} style={{ width: `${clamped}%` }} />
      </div>
      <span className="progresspct">
        {Math.round(clamped)}%
        {eta && <span className="progresseta"> · {eta} left</span>}
      </span>
    </div>
  );
}

function fmtTime(iso: string): string {
  return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}

function elapsedStr(startIso: string, endMs: number): string {
  const s = Math.max(0, Math.floor((endMs - new Date(startIso).getTime()) / 1000));
  if (s < 60) return `${s}s`;
  const m = Math.floor(s / 60);
  return `${m}m${(s % 60).toString().padStart(2, '0')}s`;
}

function relTime(startIso: string, evIso: string): string {
  return elapsedStr(startIso, new Date(evIso).getTime());
}

function useElapsed(startIso: string, running: boolean, lastEventTs?: string): string {
  const [now, setNow] = useState(() => Date.now());
  useEffect(() => {
    if (!running) return;
    const t = setInterval(() => setNow(Date.now()), 1000);
    return () => clearInterval(t);
  }, [running]);
  // For finished jobs use the last event's timestamp so we show run duration, not time-since.
  const endMs = !running && lastEventTs ? new Date(lastEventTs).getTime() : now;
  return elapsedStr(startIso, endMs);
}

const TERMINAL = new Set(['complete', 'failed', 'interrupted', 'insufficient-input']);

function fmtEvent(ev: JobEvent): { label: string; tone?: 'ok' | 'bad' | 'warn' | 'friction' | 'dim' | 'neutral' } {
  const d = ev.data;
  // SET 37i — tone from single source of truth (eventTone.ts); labels stay local.
  const tone = jobEventToneCss(ev);
  switch (ev.kind) {
    case 'phase': return { label: `→ ${phaseLabel(d.phase as string)}`, tone };
    case 'prompt-gate-enum': return { label: `input gate: enumerated ${d.features} feature(s) to probe`, tone };
    case 'prompt-gate-probe': return (d.clear as boolean)
      ? { label: `  probe ✓ ${d.feature}`, tone }
      : { label: `  probe ✗ ${d.feature} — intent unclear`, tone };
    case 'prompt-gate': return (d.sufficient as boolean)
      ? { label: `input gate ✓ — ${d.features} feature(s) demarcated`, tone }
      : { label: `input gate: ${d.deficiencies} feature(s) left intent ambiguous — surfaced to author`, tone };
    case 'gate-override': return {
      label: d.by === 'autopilot'
        ? 'input gate: autopilot override — continuing unsupervised (recorded in ledger)'
        : 'input gate: human operator override — proceeding on manual authentication',
      tone,
    };
    case 'autopilot': return {
      label: `⚡ autopilot: ${d.gate === 'input' ? 'input gate overridden' : d.gate === 'ir' ? 'IR contract auto-approved' : 'build auto-approved'} — continuing unsupervised (recorded in ledger)`,
      tone,
    };
    case 'rha-fresh-take': return { label: `⟳ entropy reset ${d.featureId}: ${d.width} fresh draw(s) from the frozen contract (baseline ${d.baseline} failing)`, tone };
    case 'ir-fresh-take': return { label: `⟳ entropy reset ${d.featureId}: ${d.width} fresh suite draw(s) (baseline ${d.baseline} findings)`, tone };
    case 'ir-fresh-take-kept': return { label: `⟳ fresh draw kept for ${d.featureId}: ${d.baseline}→${d.now} findings`, tone };
    case 'ir-escalated': return { label: `⚠ IR escalated ${d.featureId} — suite could not conform to the stubs (${d.findings} findings); excluded, run continues`, tone };
    case 'ir-escalations': return { label: `IR: ${d.count} feature(s) escalated and excluded — rest frozen`, tone };
    case 'encoding-retry': return { label: `⚠ re-encoding (${d.reason}, ${d.findings} issues)`, tone };
    case 'stub-repair': return { label: `⚠ stub repair pass ${d.pass}: fixing ${(d.files as string[]).length} file(s) (${d.findings} errors)`, tone };
    case 'fixStub': {
      const noop = d.noop ? ' · NO-OP' : '';
      return { label: `fixStub ${d.path}: ${d.beforeLen}→${d.afterLen} chars${noop}`, tone };
    }
    case 'ir-repair': return { label: `⚠ IR repair ${d.featureId} pass ${d.pass} (${d.findings} findings)`, tone };
    case 'feature-encoded': return { label: `encoded ${d.featureId} · ${d.name as string}`, tone };
    case 'stubs-ready': return { label: `interface stubs compiled (${(d.files as string[]).length} files)`, tone };
    case 'ir-generated': return { label: `IR generated: ${d.featureId}`, tone };
    case 'consistency-gate': return (d.pass as boolean)
      ? { label: 'cross-suite consistency gate ✓', tone }
      : { label: `cross-suite gate ✗ — ${d.findings} findings in ${(d.files as string[]).length} file(s)`, tone };
    case 'ir-feature': return { label: `${d.featureId}: ${d.cases} cases  (${d.edge} edge)`, tone };
    case 'build-feature': return { label: `building ${d.featureId} · ${d.name as string}`, tone };
    case 'auth-result': return {
      label: Number(d.failed) === 0
        ? `${d.featureId}: ${d.passed}/${d.passed} passed ✓`
        : `${d.featureId}: ${d.failed} failed · ${d.passed} passing ✕`,
      tone,
    };
    case 'rha-sweep': return { label: `RHA sweep #${d.episode}: ${d.findings} finding(s)`, tone };
    case 'rha-episode': return { label: `localizing ${d.bugClass} on ${d.featureId}`, tone };
    case 'rha-repair': return {
      label: `repair ${d.featureId}: ${d.outcome}`,
      tone,
    };
    case 'complete': {
      const r = d.report as { totalTests: number; passed: number; failed: number } | undefined;
      if (!r) return { label: 'complete', tone };
      return r.failed === 0
        ? { label: `complete — ${r.passed}/${r.totalTests} tests passing ✓`, tone }
        : { label: `complete — ${r.failed}/${r.totalTests} tests failing (escalated)`, tone };
    }
    case 'resume': return { label: `↩ resumed from ${d.from} — continuing from ledger`, tone };
    case 'recompute': return {
      label: `↻ recompute ${d.featureId}${d.phase ? ` · ${d.phase}` : ''}`,
      tone,
    };
    case 'rha-supervisor': return {
      label: d.error
        ? `Supervisor escalation failed on ${d.featureId}: ${d.error}`
        : `Supervisor escalation attempt on ${d.featureId} (${d.reason})`,
      tone,
    };
    case 'sev1': return {
      label: `⛔ SEV1 ${d.code}: ${d.message ?? 'algorithm breakdown — run halted'}`,
      tone,
    };
    case 'llm-usage': {
      const ms = d.ms as number | undefined;
      const t = ms != null && !isNaN(ms) ? ` · ${(ms / 1000).toFixed(1)}s` : '';
      // SET 37i — LLM calls are YELLOW (operational), never green.
      return { label: `LLM ${d.op}${d.featureId ? ' · ' + d.featureId : ''}: ${fmtTokens(d.inputTokens as number)} in / ${fmtTokens(d.outputTokens as number)} out${t}`, tone };
    }
    default: return { label: `${ev.kind}${Object.keys(d).length ? ' · ' + JSON.stringify(d).slice(0, 70) : ''}`, tone };
  }
}

function ShareButton() {
  const [copied, setCopied] = useState(false);
  const share = 'RHA Wrapper reduces token consumption and increases the accuracy of autonomous looping agents using any frontier or local LLM. https://rha.software';
  const copy = () => {
    void navigator.clipboard.writeText(share).then(() => {
      setCopied(true);
      setTimeout(() => setCopied(false), 1800);
    });
  };
  return (
    <button className="sharebtn" onClick={copy} title="Copy a one-line pitch + link to share">
      {copied ? '✓ copied to clipboard' : '↗ share RHA Wrapper'}
    </button>
  );
}

function EventLog({ events, startTs, defaultOpen }: { events: JobEvent[]; startTs: string; defaultOpen?: boolean }) {
  const bodyRef = useRef<HTMLDivElement | null>(null);
  // Audit log follows the tail: pin to the newest event as the run streams.
  useEffect(() => {
    const el = bodyRef.current;
    if (el) el.scrollTop = el.scrollHeight;
  }, [events.length]);
  if (!events.length) return null;
  return (
    <details className="eventlog" open={defaultOpen}>
      <summary>{events.length} pipeline events — full audit log</summary>
      <div className="logbody" ref={bodyRef}>
        {events.map(ev => {
          const { label, tone } = fmtEvent(ev);
          return (
            <div key={ev.seq} className={`logrow tone-${tone ?? 'neutral'}`}>
              <span className="logts">{relTime(startTs, ev.ts)}</span>
              <span className="loglabel">{label}</span>
            </div>
          );
        })}
      </div>
    </details>
  );
}

interface JobEvent { seq: number; ts: string; kind: string; data: Record<string, unknown> }

/**
 * Static-site architecture: the hosted UI (rha.software / *.pages.dev) is the
 * SAME bundle as self-host — the only difference is the pointer. Self-host
 * talks same-origin; hosted talks to the user's OWN local engine through the
 * localhost tunnel. No server-side RHA exists anywhere.
 */
const HOSTED = /(^|\.)rha\.software$|\.pages\.dev$/.test(window.location.hostname);
export const API_BASE = HOSTED
  ? (localStorage.getItem('rha-engine-url')?.replace(/\/$/, '') ?? 'http://localhost:4100')
  : '';

async function api<T>(path: string, init?: RequestInit): Promise<T> {
  const res = await fetch(API_BASE + path, { headers: { 'Content-Type': 'application/json' }, ...init });
  if (!res.ok) throw new Error((await res.json().catch(() => ({})) as any).error ?? `HTTP ${res.status}`);
  return res.json() as Promise<T>;
}

// ---------------------------------------------------------------------------

const PHASE_STEP: Record<string, number> = {
  created: 1, encoding: 1, 'generating-ir': 1, 'awaiting-ir-approval': 1,
  building: 2, 'awaiting-build-approval': 2,
  healing: 3, complete: 4, failed: 4, interrupted: 4, 'insufficient-input': 1,
};

interface ProviderPublic {
  type: string;
  baseURL?: string;
  model?: string;
  label?: string;
  connected: boolean;
  kind: 'mock' | 'local' | 'cloud';
  verifiedAt?: string;
}

interface Meta {
  mock: boolean;
  local?: boolean;
  model: string | null;
  connected?: boolean;
  kind?: 'mock' | 'local' | 'cloud' | null;
  version?: string;
  legacy?: boolean;
  canonical?: boolean;
  autopilotDefault?: boolean;
  providers?: {
    advancedMode?: boolean;
    operators?: ProviderPublic[];
    operator: ProviderPublic | null;
    supervisor: ProviderPublic | null;
    pool: ProviderPublic[];
    supervisorEnabled: boolean;
    supervisorModes?: {
      resolveIr: boolean;
      resolveAllFailures: boolean;
      resolveEscalationsOnly: boolean;
    };
  };
}

declare global {
  interface Window {
    __RHA_EXT__?: { version?: string; hash?: string; extensionId?: string };
    /** Present on pages listed in the extension's externally_connectable. */
    chrome?: {
      runtime?: {
        sendMessage?: (extId: string, msg: unknown, cb: (r: unknown) => void) => void;
        lastError?: { message?: string };
      };
    };
  }
}

/** Message the Provider Vault extension directly (web runtime, no server). */
function extSendMessage<T>(msg: Record<string, unknown>): Promise<T> {
  return new Promise((resolve, reject) => {
    const id = window.__RHA_EXT__?.extensionId;
    const rt = window.chrome?.runtime;
    if (!id || !rt?.sendMessage) { reject(new Error('Provider Vault extension not available')); return; }
    try {
      rt.sendMessage(id, msg, (r: unknown) => {
        const err = window.chrome?.runtime?.lastError;
        if (err) { reject(new Error(err.message || 'extension unreachable')); return; }
        const resp = r as { error?: string } | undefined;
        if (resp?.error) { reject(new Error(resp.error)); return; }
        resolve(r as T);
      });
    } catch (e) { reject(e as Error); }
  });
}

interface ExtRuntime {
  unlocked: boolean;
  active: { name: string; type: string; model: string; baseURL?: string; mode: string } | null;
  running: boolean;
  hasResult: boolean;
  /** SET 47d / R11 — live SW manifest version. */
  vaultVersion?: string;
}

interface Tier1Metrics {
  elapsedMs?: number;
  startedAt?: string;
  passesTotal?: number;
  passesPerFeature?: Record<string, number>;
  repairDraws?: number;
  freshDraws?: number;
  irSurgeries?: number;
  irGenAttempts?: number;
  mechanicalFixes?: number;
  templatedCases?: number;
  /** @deprecated SET 14g — prefer permBatches / permCandidates / permImproved */
  permutationDraws?: number;
  /** @deprecated SET 14g */
  permutationWins?: number;
  permBatches?: number;
  permCandidates?: number;
  permImproved?: number;
  cladeRatio?: string;
  bugsObserved?: number;
  bugsPatched?: number;
  bugsRemaining?: number;
  llmCalls?: number;
  tokensIn?: number;
  tokensOut?: number;
  maxIn?: number;
  maxOut?: number;
  avgIn?: number;
  avgOut?: number;
  perStage?: Record<string, { calls: number; tokensIn: number; tokensOut: number }>;
  agents?: {
    id: string; label: string; calls: number;
    tokensIn: number; tokensOut: number;
    perStage: Record<string, { calls: number; tokensIn: number; tokensOut: number }>;
  }[];
  model?: string | null;
  mode?: 'vanilla' | 'rha' | 'referee';
  live?: boolean;
  /** SET 45 / R9 — substrate strip. */
  substrateAttempts?: number;
  substrateFixes?: number;
  substrateRefusals?: number;
  substrateNoHandler?: number;
  /** SET 57a — run-report counters (see RunReportMetrics). */
  featuresTotal?: number;
  featuresFrozenWithoutRedraw?: number;
  irRedraws?: number;
  nodesTotal?: number;
  nodesBuiltCleanFirstPass?: number;
  boundedLoopNodes?: number;
  boundedLoopIterations?: number;
  candidatesDrawn?: number;
  candidatesAccepted?: number;
  errorsTotal?: number;
  errorsParse?: number;
  errorsConformance?: number;
  errorsExecution?: number;
  errorsCrash?: number;
  // SET 89 §§10.5 / 11.4 — canonical registry-sourced counters.
  findingOccurrencesTotal?: number;
  findingOccurrencesParse?: number;
  findingOccurrencesConformance?: number;
  findingOccurrencesExecution?: number;
  findingOccurrencesCrash?: number;
  findingOccurrencesTestFailure?: number;
  uniqueDefectsObserved?: number;
  uniqueDefectsResolved?: number;
  uniqueDefectsUnresolved?: number;
  actionStarts?: number;
  actionTerminals?: number;
  actionDecisions?: number;
  actionPending?: number;
  actionFailed?: number;
  // SET 89 §11.4 Gap E — separate counter for nonfatal action failures.
  actionFailedNonfatal?: number;
  deferredTotal?: number;
  deferredRelitigated?: number;
  deferredResolvedAfterRelitigation?: number;
  deferredPermanent?: number;
  nodesHealed?: number;
  fixesUnsuccessful?: number;
  fixesUnparseable?: number;
  minIn?: number;
  minOut?: number;
}

interface RunCostSnapshot {
  kind: 'local' | 'estimated' | 'unavailable';
  label: string;
  usdTotal: number | null;
  usdIn: number | null;
  usdOut: number | null;
  matchedId?: string;
  modelId: string;
  tokensIn: number;
  tokensOut: number;
  comparative: { label: string; id: string; usdTotal: number; usdIn: number; usdOut: number }[];
  provenance: string;
  fetchedAt: string;
  stale?: boolean;
}

interface Tier1Status {
  running: boolean;
  /** SET 22d — token-ceiling pause (issuance stopped; Resume / Cancel). */
  paused?: boolean;
  totalEvents?: number;
  id?: string | null;
  /** SET 14b — ISO start time for client-side elapsed tick. */
  startedAt?: string | null;
  prompt?: string | null;
  model?: string | null;
  mode?: 'vanilla' | 'rha' | 'referee';
  metrics?: Tier1Metrics | null;
  cost?: RunCostSnapshot | null;
  counterfactual?: {
    failuresTotal: number;
    classified: number;
    mechanicalWouldFix: number;
    templatePrevented: number;
    summary: string;
  } | null;
  projectId?: string | null;
  corpus?: { appVersion?: string; generatedAt?: string; offline?: boolean; error?: string } | null;
  contractVersion?: string | null;
  events: { ts: string; phase: string; featureId: string | null; message: string }[];
  error?: string | null;
  hasResult?: boolean;
  summary?: { title: string; features: number; suites: number; problems: string[] } | null;
  /** SET 51 / 56a — viewer honesty fields from tier1:record. */
  status?: string | null;
  checkpointAt?: string | null;
  finalized?: boolean;
  vaultVersion?: string | null;
}

/** SET 56a — static Tier1Status from a stored record (non-live selection). */
function tier1StatusFromRecord(rec: {
  id?: string;
  ts?: string;
  model?: string | null;
  events?: Tier1Status['events'];
  mode?: string | null;
  problems?: string[] | null;
  error?: string | null;
  metrics?: Tier1Metrics | null;
  cost?: RunCostSnapshot | null;
  projectId?: string | null;
  status?: string | null;
  checkpointAt?: string | null;
  finalized?: boolean;
  running?: boolean;
  prompt?: string | null;
  contractVersion?: string | null;
}): Tier1Status {
  const problems = Array.isArray(rec.problems) ? rec.problems : [];
  return {
    running: false,
    id: rec.id ?? null,
    startedAt: rec.ts ?? null,
    prompt: rec.prompt ?? null,
    model: rec.model ?? null,
    mode: (rec.mode as Tier1Status['mode']) || 'rha',
    metrics: rec.metrics ?? null,
    cost: rec.cost ?? null,
    projectId: rec.projectId ?? rec.id ?? null,
    events: Array.isArray(rec.events) ? rec.events : [],
    totalEvents: Array.isArray(rec.events) ? rec.events.length : 0,
    error: rec.error ?? null,
    hasResult: problems.length === 0 && !rec.error,
    summary: {
      title: '',
      features: 0,
      suites: 0,
      problems,
    },
    status: rec.status ?? null,
    checkpointAt: rec.checkpointAt ?? null,
    finalized: rec.finalized === true,
    contractVersion: rec.contractVersion ?? null,
  };
}

function fmtUsd(n: number | null | undefined, digits = 4): string {
  if (n == null || !Number.isFinite(n)) return '—';
  if (n === 0) return '$0.00';
  if (Math.abs(n) < 0.0001) return `$${n.toExponential(2)}`;
  return `$${n.toFixed(digits)}`;
}

/** SET 37i — Tier 1 tone from eventTone.ts (single source of truth). */
function t1Tone(e: { phase: string; message: string }): 'ok' | 'bad' | 'warn' | 'friction' | 'dim' | 'neutral' {
  return t1EventToneCss(e);
}

type Tier1PipelineEpoch = {
  id: string;
  ts: string;
  model?: string | null;
  /** Live / hydrated run shown in `t1` — events come from status, not storage. */
  isCurrent?: boolean;
};

type Tier1LogEvent = { ts: string; phase: string; featureId: string | null; message: string };

/** SET 56b S6 — collapse defaults: pipeline log open, prompt collapsed. */
const COLLAPSE_PIPELINE_KEY = 'rha.collapse.pipelineLog';
const COLLAPSE_PROMPT_KEY = 'rha.collapse.prompt';
const COLLAPSE_RUN_REPORT_KEY = 'rha.collapse.runReport';

function readCollapseOpen(key: string, defaultOpen: boolean): boolean {
  try {
    const v = localStorage.getItem(key);
    if (v === null) return defaultOpen;
    return v !== '0';
  } catch {
    return defaultOpen;
  }
}

function writeCollapseOpen(key: string, open: boolean): void {
  try {
    localStorage.setItem(key, open ? '1' : '0');
  } catch { /* private mode */ }
}

type BugReportPayload = {
  prompt: string;
  events: Tier1LogEvent[];
  problems: string[];
  metrics: unknown;
  versions: { vault: string; corpus: string; app: string };
  contact?: string;
};

/** Web-runtime compile view: the pipeline runs inside the extension. */
function Tier1View({ t1, epochs, pipelineEpochs, busy, onExport, onContinue, onCancel, onResume, onClose }: {
  t1: Tier1Status | null;
  /** Prompt epochs for this project, oldest first: each submission to the queue. */
  epochs: { id?: string | null; ts: string; model?: string | null; prompt: string }[];
  /** Pipeline-log epochs (same queue), oldest first — prior runs lazy-load via tier1:record. */
  pipelineEpochs: Tier1PipelineEpoch[];
  busy: boolean;
  onExport: () => void;
  onContinue: (prompt: string) => void;
  onCancel: () => void;
  onResume?: () => void;
  onClose: () => void;
}) {
  const [nextPrompt, setNextPrompt] = useState('');
  const [tick, forceTick] = useState(0);
  const logRef = useRef<HTMLDivElement | null>(null);
  const pinToBottomRef = useRef(true);
  const faviconFrameRef = useRef(0);
  const [priorEvents, setPriorEvents] = useState<Record<string, Tier1LogEvent[]>>({});
  /** SET 41c — prior epochs whose tier1:record fetch failed (not merely empty). */
  const [missingPriorRecords, setMissingPriorRecords] = useState<Record<string, true>>({});
  // SET 33b.2 — "this project" metrics scope needs the full stats-shape of
  // every prior epoch (metrics/cost/mode/problems), not just its events.
  const [priorRunRecords, setPriorRunRecords] = useState<Record<string, RunRecordLike>>({});
  // SET 56b S6 — collapsibles persist in localStorage.
  const [logOpen, setLogOpen] = useState(() => readCollapseOpen(COLLAPSE_PIPELINE_KEY, true));
  const [promptLogOpen, setPromptLogOpen] = useState(() => readCollapseOpen(COLLAPSE_PROMPT_KEY, false));
  const [runReportOpen, setRunReportOpen] = useState(() => readCollapseOpen(COLLAPSE_RUN_REPORT_KEY, true));
  const [reportOpen, setReportOpen] = useState(false);
  const [reportConsent, setReportConsent] = useState(false);
  const [reportContact, setReportContact] = useState('');
  const [reportBusy, setReportBusy] = useState(false);
  const [reportMsg, setReportMsg] = useState<string | null>(null);
  const events = t1?.events ?? [];
  // SET 34 — claim-bound evidence derived from events/problems (no offscreen change).
  // SET 37b — strata table from seed until Stream A's solutionStrata.generated lands.
  const reportEvidence = useMemo(
    () => collectReportEvidence(events, t1?.summary?.problems ?? [], {
      solutionStrata: SEED_SOLUTION_STRATA,
    }),
    [events, t1?.summary?.problems],
  );

  const exportLogInput = () => {
    if (!t1?.id) return null;
    return {
      id: t1.id,
      ts: events[0]?.ts ?? new Date().toISOString(),
      model: t1.model,
      prompt: t1.prompt,
      events,
      problems: t1.summary?.problems ?? (t1.error ? [t1.error] : []),
      evidence: reportEvidence.lines,
      metrics: (t1.metrics as Record<string, unknown> | null | undefined) ?? null,
      epochs,
      contractVersion: t1.contractVersion,
      vaultVersion: window.__RHA_EXT__?.version ?? null,
      corpusVersion: t1.corpus?.appVersion ?? null,
    };
  };

  const onExportLog = () => {
    const input = exportLogInput();
    if (!input) { alert('No run to export yet'); return; }
    downloadRunLogZip(input);
  };

  const hasProblems = !!(t1?.summary?.problems?.length || t1?.error);
  const showReport = !!(t1 && !t1.running && hasProblems);

  const bugPayload = (): BugReportPayload | null => {
    if (!t1) return null;
    return {
      prompt: t1.prompt ?? '',
      events,
      problems: t1.summary?.problems ?? (t1.error ? [t1.error] : []),
      metrics: t1.metrics ?? null,
      versions: {
        vault: window.__RHA_EXT__?.version ?? 'unknown',
        corpus: t1.corpus?.appVersion ?? 'unknown',
        app: t1.corpus?.appVersion ?? t1.contractVersion?.split('+')[0] ?? 'unknown',
      },
      ...(reportContact.trim() ? { contact: reportContact.trim() } : {}),
    };
  };

  const submitBugReport = async () => {
    const payload = bugPayload();
    if (!payload || !reportConsent) return;
    setReportBusy(true);
    setReportMsg(null);
    try {
      const r = await fetch('/api/bug-report', {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify(payload),
      });
      const j = await r.json().catch(() => ({})) as { id?: string; error?: string };
      if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`);
      setReportMsg(`Report submitted — id ${j.id}`);
      setReportConsent(false);
    } catch (e) {
      setReportMsg(e instanceof Error ? e.message : String(e));
    } finally {
      setReportBusy(false);
    }
  };
  useEffect(() => {
    if (t1?.running || !t1?.summary) {
      setLogOpen(true);
      writeCollapseOpen(COLLAPSE_PIPELINE_KEY, true);
    }
  }, [t1?.running, t1?.summary]);
  // 1s pulse while running so elapsed/quiet counters visibly tick.
  useEffect(() => {
    if (!t1?.running) return;
    const t = setInterval(() => forceTick(n => n + 1), 1000);
    return () => clearInterval(t);
  }, [t1?.running]);
  // SET 56c R6 — autoscroll while pinned; suspend when the user scrolls up.
  useEffect(() => {
    const el = logRef.current;
    if (el && pinToBottomRef.current) el.scrollTop = el.scrollHeight;
  }, [events.length, priorEvents, logOpen]);
  // Lazy-load prior epoch event blocks (+ the record fields "this project"
  // metrics need — SET 33b.2) from SW storage; never hydrate offscreen.
  // Events are only READ when the log is opened (cheap either way, but the
  // metrics-grid fields below load unconditionally so the project scope
  // populates without the user expanding the pipeline log).
  const pipelineEpochKey = pipelineEpochs.map(e => `${e.id}:${e.isCurrent ? 1 : 0}`).join('|');
  useEffect(() => {
    if (!logOpen) return;
    let cancelled = false;
    const load = async () => {
      for (const ep of pipelineEpochs) {
        if (ep.isCurrent) continue;
        try {
          const r = await extSendMessage<{ events?: Tier1LogEvent[]; error?: string }>({
            action: 'tier1:record',
            runId: ep.id,
          });
          if (cancelled) return;
          if (r?.error) {
            setMissingPriorRecords(prev => (prev[ep.id] ? prev : { ...prev, [ep.id]: true }));
            setPriorEvents(prev => (prev[ep.id] ? prev : { ...prev, [ep.id]: [] }));
            continue;
          }
          setPriorEvents(prev => (prev[ep.id] ? prev : { ...prev, [ep.id]: r.events ?? [] }));
        } catch {
          if (cancelled) return;
          setMissingPriorRecords(prev => (prev[ep.id] ? prev : { ...prev, [ep.id]: true }));
          setPriorEvents(prev => (prev[ep.id] ? prev : { ...prev, [ep.id]: [] }));
        }
      }
    };
    void load();
    return () => { cancelled = true; };
    // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed by pipelineEpochKey
  }, [logOpen, pipelineEpochKey]);

  useEffect(() => {
    let cancelled = false;
    const load = async () => {
      for (const ep of pipelineEpochs) {
        if (ep.isCurrent) continue;
        try {
          const r = await extSendMessage<{
            ts?: string; mode?: string | null; problems?: number | null; error?: string | null;
            metrics?: Record<string, unknown> | null; cost?: { usdTotal?: number | null } | null;
            projectId?: string | null;
            status?: string | null;
            checkpointAt?: string | null;
            finalized?: boolean;
          }>({ action: 'tier1:record', runId: ep.id });
          if (cancelled) return;
          setPriorRunRecords(prev => (prev[ep.id] ? prev : {
            ...prev,
            [ep.id]: {
              id: ep.id,
              ts: r.ts ?? ep.ts,
              mode: r.mode ?? undefined,
              problems: r.problems ?? null,
              error: r.error ?? null,
              metrics: r.metrics ?? null,
              cost: r.cost ?? null,
              projectId: r.projectId ?? null,
              status: r.status ?? null,
              checkpointAt: r.checkpointAt ?? null,
              finalized: r.finalized === true,
            },
          }));
        } catch {
          if (cancelled) return;
          setPriorRunRecords(prev => (prev[ep.id] ? prev : { ...prev, [ep.id]: { id: ep.id, ts: ep.ts, projectId: ep.id } }));
        }
      }
    };
    void load();
    return () => { cancelled = true; };
    // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed by pipelineEpochKey
  }, [pipelineEpochKey]);

  // SET 56b S7 — install-lifetime / GLOBAL aggregates live only in Admin /
  // install overlays. Run view keeps this-epoch + this-project only.
  const startTs = t1?.startedAt || t1?.metrics?.startedAt || events[0]?.ts || new Date().toISOString();
  // SET 29b — tab-stub indicator: prefix document.title while the run is
  // live (load-bearing — visible from any other tab). Restored the moment
  // the run stops, and as an unmount safety net below.
  useEffect(() => {
    if (!t1?.running) { document.title = BASE_TAB_TITLE; return; }
    document.title = formatRunningTabTitle(elapsedMinutesSince(startTs));
    // eslint-disable-next-line react-hooks/exhaustive-deps -- `tick` drives the per-minute re-render
  }, [t1?.running, startTs, tick]);
  // Favicon spin (enhancement only — title prefix above is the load-bearing part).
  useEffect(() => {
    const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
    if (!link || !t1?.running) return;
    const original = link.href;
    faviconFrameRef.current = 0;
    link.href = SPIN_FAVICON_FRAMES[0];
    const t = setInterval(() => {
      faviconFrameRef.current = (faviconFrameRef.current + 1) % SPIN_FAVICON_FRAMES.length;
      link.href = SPIN_FAVICON_FRAMES[faviconFrameRef.current];
    }, 500);
    return () => { clearInterval(t); link.href = original; };
  }, [t1?.running]);
  // Unmount safety net: never leave the tab stuck showing a stale "⟳ Nm" title.
  useEffect(() => () => { document.title = BASE_TAB_TITLE; }, []);
  const priorEventCount = pipelineEpochs
    .filter(ep => !ep.isCurrent)
    .reduce((n, ep) => n + (priorEvents[ep.id]?.length ?? 0), 0);
  const pipelineEventCount = priorEventCount + events.length;
  const ongoing = !!(t1?.projectId && t1.id && t1.projectId !== t1.id);
  const cycles = cyclesFromMetrics(t1?.metrics);
  // SET 33b.2 — "this project" scope: the current run's own record (from
  // live t1 state) plus every prior epoch's persisted record, aggregated
  // with the SAME facet catalogue as the global/run grids.
  const pid = t1?.projectId || t1?.id || null;
  const projectStats = useMemo(() => {
    if (!pid) return null;
    const currentRec: RunRecordLike | null = t1
      ? {
          id: t1.id ?? pid,
          ts: t1.startedAt || t1.metrics?.startedAt || events[0]?.ts || new Date().toISOString(),
          mode: t1.mode,
          problems: t1.summary?.problems?.length ?? null,
          error: t1.error ?? null,
          result: t1.hasResult ? {} : undefined,
          metrics: (t1.metrics as unknown as Record<string, unknown>) ?? null,
          cost: t1.cost ? { usdTotal: t1.cost.usdTotal } : null,
          live: !!t1.running,
          projectId: pid,
        }
      : null;
    const records = [
      ...(currentRec ? [currentRec] : []),
      ...Object.values(priorRunRecords),
    ];
    return aggregateProjectStats(records, pid);
  }, [pid, t1, events, priorRunRecords]);
  const dot = <span style={{ color: 'var(--accent)', margin: '0 6px' }}>·</span>;
  return (
    <div style={{ flex: 1, overflow: 'auto' }}>
      <div className="jobhead">
        <h2>Web compile</h2>
        {t1?.id ? <span className="jid">{t1.id}</span> : null}
        {(t1?.mode || t1?.metrics?.mode) === 'vanilla'
          ? <span className="dimbadge" title="Single draw — no scaffolding, no repair">vanilla</span>
          : null}
        {ongoing
          ? <span className="supbadge" title="Continues an existing project — compiled against its delivered features">ongoing project</span>
          : <span className="livebadge" title="First run of a new project">fresh build</span>}
      </div>

      {/* Caption: white, non-italic, small — clauses demarcated by purple dots. */}
      <p style={{ color: 'var(--ink)', fontStyle: 'normal', fontSize: 12, margin: '0 0 14px' }}>
        Corpus from rha.software{dot}compute via your Provider Vault LLM{dot}
        the artifact is a real git repository, downloadable when the run completes.
      </p>

      {t1?.error && <div className="error">{t1.error}</div>}

      {t1?.running && t1.paused && (
        <div className="pause-banner" role="status">
          <span><b>run paused:</b> runaway token burn — Resume / Cancel</span>
          <span className="pause-actions">
            <button type="button" className="ghostbtn" onClick={() => onResume?.()}>Resume</button>
            <button
              type="button"
              className="clearall"
              onClick={() => {
                if (confirm('Cancel this job? The current draw finishes, then the run stops.')) onCancel();
              }}
            >
              Cancel
            </button>
          </span>
        </div>
      )}

      {/* Export log available for live snapshots too (not only completed runs). */}
      {t1?.running && t1.id && (
        <div style={{ marginBottom: 10 }}>
          <button className="ghostbtn" onClick={onExportLog}>Export log</button>
        </div>
      )}

      {/* Failed run with no summary card — still offer report + export. */}
      {!t1?.running && t1?.error && !t1?.summary && (
        <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 14 }}>
          <button className="ghostbtn" onClick={onExportLog}>Export log</button>
          {showReport && (
            <button
              className="ghostbtn"
              onClick={() => { setReportOpen(true); setReportMsg(null); setReportConsent(false); }}
            >
              Report a problem
            </button>
          )}
        </div>
      )}

      {/* Live pulse — elapsed + quiet only; cycles/tokens/calls live in Run metrics (SET 14h). */}
      {t1?.running && (() => {
        const lastTs = events.length ? new Date(events[events.length - 1].ts).getTime() : Date.now();
        const quietS = Math.max(0, Math.round((Date.now() - lastTs) / 1000));
        const elapsedS = Math.max(0, Math.floor((Date.now() - new Date(startTs).getTime()) / 1000));
        const tone = quietS > 900 ? 'bad' : quietS > 240 ? 'warn' : 'ok';
        const note = quietS > 900 ? ' — possibly hung; check LM Studio'
          : quietS > 240 ? ' — long quiet (host probe runs in the pipeline)'
          : '';
        return (
          <p className="small" style={{ margin: '0 0 8px' }}>
            <span className="spin">⟳</span>{' '}
            <span className="dim">elapsed</span> {formatHms(elapsedS)}{' '}
            <span className="dim">· last event</span>{' '}
            <span className={tone}>{formatHms(quietS)} ago{note}</span>
          </p>
        );
      })()}

      {/* Prompt Box — only when the run is complete. */}
      {t1?.summary && !t1.running && (
        <div className="card" style={{ marginBottom: 14 }}>
          <div className="cardhead">
            <b>{t1.summary.title}</b>
            <span className="deps">{t1.summary.features} features · {t1.summary.suites} suites frozen</span>
          </div>
          {t1.summary.problems.length > 0 && (
            <div className="error" style={{ marginTop: 8 }}>{t1.summary.problems.join('\n')}</div>
          )}
          {/* SET 34 — claim-bound report evidence (seam dump, spec hash, efficacy…). */}
          {reportEvidence.lines.length > 0 && (
            <div className="report-evidence" style={{ marginTop: 8 }}>
              <div className="cardhead"><b>Evidence</b></div>
              {reportEvidence.specHash && (
                <div className="evidence-line">{reportEvidence.specHash}</div>
              )}
              {reportEvidence.efficacy && (
                <div className="evidence-line">{reportEvidence.efficacy}</div>
              )}
              {reportEvidence.conceptEfficacy && (
                <div className="evidence-line">{reportEvidence.conceptEfficacy}</div>
              )}
              {reportEvidence.plannerBypass.map((line, i) => (
                <div key={`pb-${i}`} className="evidence-line">{line}</div>
              ))}
              {reportEvidence.parsePoison && (
                <div className="evidence-line">{reportEvidence.parsePoison}</div>
              )}
              {reportEvidence.solutionStrata.length > 0 && (
                <div className="evidence-block evidence-strata">
                  {reportEvidence.solutionStrata.map((line, i) => (
                    <div key={`ss-${i}`} className="evidence-line">{line}</div>
                  ))}
                </div>
              )}
              {reportEvidence.permanentDeferrals.map((line, i) => (
                <div key={`def-${i}`} className="evidence-line">{line}</div>
              ))}
              {reportEvidence.continueProjectNote && (
                <div className="evidence-line">{reportEvidence.continueProjectNote}</div>
              )}
              {(reportEvidence.attributionMiss.length > 0 || reportEvidence.seamDumps.length > 0) && (
                <div className="evidence-block">
                  {reportEvidence.attributionMiss.map((line, i) => (
                    <div key={`am-${i}`} className="evidence-line">{line}</div>
                  ))}
                  {reportEvidence.crashLines.map((line, i) => (
                    <div key={`cr-${i}`} className="evidence-line">{line}</div>
                  ))}
                  {reportEvidence.seamDumps.map((line, i) => (
                    <div key={`sd-${i}`} className="evidence-line evidence-seam">{line}</div>
                  ))}
                </div>
              )}
              {reportEvidence.parseDrops.map((line, i) => (
                <div key={`pd-${i}`} className="evidence-line">{line}</div>
              ))}
            </div>
          )}
          <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 10 }}>
            <button className="primary" onClick={onExport}>
              Download repository (.zip)
            </button>
            <button className="ghostbtn" onClick={onExportLog}>
              Export log
            </button>
            {showReport && (
              <button
                className="ghostbtn"
                onClick={() => { setReportOpen(true); setReportMsg(null); setReportConsent(false); }}
              >
                Report a problem
              </button>
            )}
          </div>
          <div style={{ marginTop: 14 }}>
            <div className="cardhead"><b>Continue this project</b></div>
            <p className="dim small" style={{ margin: '0 0 6px' }}>
              Previously-dropped cases replan first (coverage backfill), then new features compile against what&apos;s already delivered.
            </p>
            <textarea
              value={nextPrompt}
              onChange={e => setNextPrompt(e.target.value)}
              placeholder={busy ? 'Locked — a process is underway…' : 'Next features — one per line, observable behavior + edge cases.'}
              rows={3}
              disabled={busy}
              style={{ width: '100%', resize: 'vertical', opacity: busy ? 0.45 : 1 }}
            />
            <button
              className="primary"
              style={{ marginTop: 6, opacity: busy ? 0.45 : 1 }}
              disabled={busy || !nextPrompt.trim()}
              title={busy ? 'A process is underway — wait for it to finish' : undefined}
              onClick={() => { onContinue(nextPrompt.trim()); setNextPrompt(''); }}
            >
              Compile into project
            </button>
          </div>
        </div>
      )}

      {/* Pipeline Log — epoch-demarcated queue (same as Prompt Log), full history. */}
      <details
        className="eventlog card"
        style={{ marginBottom: 14 }}
        open={logOpen}
        onToggle={e => {
          const open = (e.target as HTMLDetailsElement).open;
          setLogOpen(open);
          writeCollapseOpen(COLLAPSE_PIPELINE_KEY, open);
        }}
      >
        <summary>
          <b>Pipeline log</b> — {pipelineEventCount} event{pipelineEventCount === 1 ? '' : 's'}
        </summary>
        <div
          className="logbody"
          ref={logRef}
          style={{ maxHeight: '46vh', overflow: 'auto', marginTop: 8 }}
          onScroll={e => {
            const el = e.currentTarget;
            pinToBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48;
          }}
        >
          {/* SET 56d — stage stepper (focus = current epoch, else last). */}
          {(() => {
            if (!pipelineEpochs.length) return null;
            const focus = pipelineEpochs.find(ep => ep.isCurrent) ?? pipelineEpochs[pipelineEpochs.length - 1]!;
            const focusEvents = focus.isCurrent ? events : (priorEvents[focus.id] ?? null);
            if (focusEvents === null) return null;
            const focusRunning = !!(focus.isCurrent && t1?.running);
            const stages = stageProgress(focusEvents, focusRunning);
            return (
              <div className="stage-stepper" aria-label="Pipeline stages">
                {stages.map(s => (
                  <div key={s.phase} className={`stage-step stage-${s.status}`}>
                    <span className="stage-label">{s.label}</span>
                    {s.elapsedMs != null ? (
                      <span className="stage-elapsed">{formatStageElapsed(s.elapsedMs)}</span>
                    ) : null}
                  </div>
                ))}
              </div>
            );
          })()}
          {pipelineEpochs.length ? pipelineEpochs.map((ep, epIdx) => {
            const epEvents = ep.isCurrent ? events : (priorEvents[ep.id] ?? null);
            const epStart = epEvents?.[0]?.ts ?? ep.ts;
            // SET 56c R5 — `#N · <toLocaleString(ts)> · <model>`
            const epochHeader = `#${epIdx + 1} · ${new Date(ep.ts).toLocaleString()}${ep.model ? ` · ${ep.model}` : ''}`;
            return (
              <div key={ep.id} style={{ marginBottom: epIdx < pipelineEpochs.length - 1 ? 12 : 0 }}>
                <div
                  className="logrow tone-accent"
                  style={{ borderTop: epIdx ? '1px solid var(--line)' : undefined, paddingTop: epIdx ? 8 : 0 }}
                >
                  <span className="loglabel">{epochHeader}</span>
                </div>
                {(() => {
                  // SET 51a / R8 — truncated checkpoint honesty (prior epochs).
                  const priorRec = !ep.isCurrent ? priorRunRecords[ep.id] : null;
                  const viewingRec = ep.isCurrent && t1 && isCheckpointedUnfinalized({
                    status: t1.status,
                    checkpointAt: t1.checkpointAt,
                    finalized: t1.finalized,
                    running: t1.running,
                  }) ? t1 : null;
                  const ckptSrc = priorRec && isCheckpointedUnfinalized(priorRec)
                    ? priorRec
                    : viewingRec;
                  const ckptHeader = ckptSrc
                    ? formatCheckpointHeader(
                      ('checkpointAt' in ckptSrc && ckptSrc.checkpointAt)
                        || ('ts' in ckptSrc && (ckptSrc as { ts?: string }).ts)
                        || ep.ts,
                    )
                    : null;
                  const recordMissing = !ep.isCurrent && !!missingPriorRecords[ep.id];
                  return (
                    <>
                      {ckptHeader ? (
                        <div className="small" style={{ paddingLeft: 8, marginBottom: 4, color: 'var(--tone-friction)' }}>
                          {ckptHeader}
                        </div>
                      ) : null}
                      {epEvents === null ? (
                        <div className="dim small" style={{ paddingLeft: 8 }}>loading…</div>
                      ) : epEvents.length ? (() => {
                        let prevCanon: StagePhase | null = null;
                        return epEvents.map((e, i) => {
                          const canon = canonicalizePhase(e.phase, prevCanon);
                          const showBreak = prevCanon !== null && canon !== prevCanon;
                          prevCanon = canon;
                          const substrate = isSubstrateEvent({ phase: e.phase, message: e.message });
                          return (
                            <React.Fragment key={`${ep.id}-${i}`}>
                              {showBreak ? (
                                <div className="logrow stage-break">— {STAGE_LABELS[canon]} —</div>
                              ) : null}
                              <div
                                title={e.phase}
                                className={`logrow tone-${t1Tone(e)}${substrate ? ' substrate-mark' : ''}`}
                              >
                                <span className="logts">{formatRelOffset(epStart, e.ts)}</span>
                                <span className="loglabel">
                                  <span className="logphase">{`[${e.phase}]`}</span>
                                  {' '}
                                  {e.featureId ? `${e.featureId} · ` : ''}{displayPipelineMessage(e.message)}
                                </span>
                              </div>
                            </React.Fragment>
                          );
                        });
                      })() : !ckptHeader ? (
                        <div
                          className={`small${
                            recordMissing || (ep.isCurrent && !t1?.running && !!t1?.id)
                              ? ' bad'
                              : ' dim'
                          }`}
                          style={{ paddingLeft: 8 }}
                        >
                          {recordMissing
                            ? RUN_RECORD_MISSING
                            : emptyPipelineLogLabel({
                              isCurrent: !!ep.isCurrent,
                              running: !!t1?.running,
                              recordMissing: false,
                            })}
                        </div>
                      ) : null}
                    </>
                  );
                })()}
              </div>
            );
          }) : (
            <div
              className={`small${t1?.id && !t1?.running ? ' bad' : ' dim'}`}
              style={{ paddingLeft: 8 }}
            >
              {emptyPipelineLogLabel({
                isCurrent: true,
                running: !!t1?.running || !t1?.id,
              })}
            </div>
          )}
        </div>
      </details>

      {/* SET 57c — Run Report: scorecard + six categories, this run only. */}
      {t1?.metrics && (
        <details
          className="card"
          style={{ marginBottom: 14 }}
          open={runReportOpen}
          onToggle={e => {
            const open = (e.target as HTMLDetailsElement).open;
            setRunReportOpen(open);
            writeCollapseOpen(COLLAPSE_RUN_REPORT_KEY, open);
          }}
        >
          <summary>
            <b>Run report</b> <span className="dim small">— this run only</span>
          </summary>
          <div style={{ marginTop: 8 }}>
            <RunReport metrics={t1.metrics} costUsd={t1.cost?.usdTotal} />
          </div>
        </details>
      )}

      {/* Run metrics — time, cycles, tokens, bugs; live during the run. */}
      {t1?.metrics && (() => {
        const m = t1.metrics!;
        const elapsedS = t1.running
          ? Math.max(0, Math.floor((Date.now() - new Date(startTs).getTime()) / 1000))
          : Math.max(0, Math.floor((m.elapsedMs ?? 0) / 1000));
        // SET 35e — permutationDraws are candidates, not batches.
        const permBatches = m.permBatches ?? 0;
        const permCandidates = m.permCandidates ?? m.permutationDraws ?? 0;
        const permImproved = m.permImproved ?? m.permutationWins ?? 0;
        const showPerm = permBatches > 0 || permCandidates > 0 || permImproved > 0;
        const ppf = m.passesPerFeature ?? {};
        const ppfEntries = Object.entries(ppf);
        const hasPerCall = m.maxIn != null || m.maxOut != null || m.avgIn != null || m.avgOut != null;
        // SET 24b — shared grid (same facet catalogue as Admin's lifetime view).
        // SET 32e.ii — live in-progress snapshots MUST be tagged `live: true`
        // so classifyRunOutcome returns 'running', not 'failed', mid-run.
        const runSnap = runSnapshot({
          metrics: m as unknown as Record<string, unknown>,
          problems: t1.summary?.problems?.length ?? null,
          error: t1.error ?? null,
          result: t1.hasResult ? {} : undefined,
          cost: t1.cost ? { usdTotal: t1.cost.usdTotal } : null,
          mode: t1.mode,
          live: !!t1.running,
        });
        const runGrid = buildRunMetricsGrid(runSnap);
        return (
        <div className="card" style={{ marginBottom: 14 }}>
          <div className="cardhead">
            <b>Run metrics</b>
            {ongoing && (
              <span
                className="dim small"
                title="Resets to zero at the start of every compile — this project's prior epochs are not summed in here"
              >
                this epoch
              </span>
            )}
            {m.live ? <span className="spin" style={{ marginLeft: 6 }}>· live</span> : null}
            {m.model ? <span className="deps">{m.model}</span> : null}
          </div>
          <MetricsGridTable
            rows={runGrid}
            columns={['latest', 'avg', 'max']}
            labels={{ latest: 'This run', avg: 'Avg', max: 'Max' }}
          />
          <div className="meta" style={{ fontSize: 12, gap: 14, marginBottom: 8, marginTop: 10 }}>
            <span><b className="ok">{formatHms(elapsedS)}</b> elapsed</span>
            <span><b className="ok">{cycles}</b> cycles</span>
            <span><b className="ok">{m.bugsPatched ?? 0}</b>/{m.bugsObserved ?? 0} bugs patched</span>
            {(m.bugsRemaining ?? 0) > 0 && <span><b className="bad">{m.bugsRemaining}</b> remaining</span>}
            <span><b className="ok">{m.llmCalls ?? 0}</b> LLM calls</span>
            <span><b className="ok">{((m.tokensIn ?? 0) + (m.tokensOut ?? 0)).toLocaleString()}</b> tokens
              <span className="dim"> ({(m.tokensIn ?? 0).toLocaleString()} in / {(m.tokensOut ?? 0).toLocaleString()} out)</span></span>
            {hasPerCall && (
              <span className="dim" title="Per LLM call">
                per-call max {(m.maxIn ?? 0).toLocaleString()} in / {(m.maxOut ?? 0).toLocaleString()} out
                {' '}· avg {(m.avgIn ?? 0).toLocaleString()} in / {(m.avgOut ?? 0).toLocaleString()} out
              </span>
            )}
            {(m.mechanicalFixes ?? 0) > 0 && (
              <span><b className="ok">{m.mechanicalFixes}</b> substrate fixes (0 tok)</span>
            )}
            {((m.substrateAttempts ?? 0) + (m.substrateFixes ?? 0) + (m.substrateRefusals ?? 0) + (m.substrateNoHandler ?? 0)) > 0 && (
              <span className="dim">
                substrate {m.substrateAttempts ?? 0} attempts / {m.substrateFixes ?? 0} fixes / {m.substrateRefusals ?? 0} refusals / {m.substrateNoHandler ?? 0} no-handler
              </span>
            )}
            {(m.templatedCases ?? 0) > 0 && (
              <span><b className="ok">{m.templatedCases}</b> templated cases</span>
            )}
            {t1.cost?.usdTotal != null && (
              <span>est. {fmtUsd(t1.cost.usdTotal)}</span>
            )}
            {showPerm && (
              <span title="Permutation candidates also count in their stage's call/token totals">
                <b className="ok">{permBatches}</b> perm batches
                {' · '}<b className="ok">{permCandidates}</b> candidates
                {' · '}<b className="ok">{permImproved}</b> improved
                <span className="dim"> (candidates counted in stage totals)</span>
              </span>
            )}
            {m.cladeRatio && (
              <span className="dim">clades {m.cladeRatio}</span>
            )}
            {(m.irGenAttempts ?? 0) > 0 && (
              <span className="dim">{m.irGenAttempts} IR gen attempts</span>
            )}
            <span className="dim">
              {m.repairDraws ?? 0} repairs ·{' '}
              <span title="Entropy-reset draws only (initial builds are build stage)">
                {m.freshDraws ?? 0} fresh draws
              </span>
              {' · '}{m.irSurgeries ?? 0} IR surgeries
            </span>
          </div>
          {(m.agents ?? (m.perStage ? [{
            id: 'operator', label: m.model || 'operator',
            calls: m.llmCalls ?? 0,
            tokensIn: m.tokensIn ?? 0, tokensOut: m.tokensOut ?? 0,
            perStage: m.perStage,
          }] : [])).map(a => (
            <div key={a.id} style={{ border: '1px solid var(--ok)', borderRadius: 8, padding: '8px 12px', marginTop: 8 }}>
              <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4 }}>
                <b style={{ color: 'var(--ok)' }}>{a.id}</b>
                <span className="dim">{a.label}</span>
                <span><b className="ok">{a.calls}</b> <span className="dim">calls</span>{' '}
                  <b className="ok">{(a.tokensIn + a.tokensOut).toLocaleString()}</b>{' '}
                  <span className="dim">tok ({a.tokensIn.toLocaleString()}↓ {a.tokensOut.toLocaleString()}↑)</span></span>
              </div>
              <table className="authtable" style={{ fontSize: 12 }}>
                <thead><tr><td className="dim">stage</td><td className="dim">calls</td><td className="dim">tokens in</td><td className="dim">tokens out</td></tr></thead>
                <tbody>
                  {Object.entries(a.perStage).map(([op, s]) => (
                    <tr key={op}>
                      <td><code>{op}</code></td>
                      <td>{s.calls}</td>
                      <td>{s.tokensIn.toLocaleString()}</td>
                      <td>{s.tokensOut.toLocaleString()}</td>
                    </tr>
                  ))}
                </tbody>
              </table>
              <StageTokensChart perStage={a.perStage} />
            </div>
          ))}
          {ppfEntries.length > 0 && (
            <div className="runchart-row" style={{ marginTop: 10 }}>
              <table className="authtable" style={{ fontSize: 12, maxWidth: 280 }}>
                <thead>
                  <tr>
                    <td className="dim">Feature</td>
                    <td className="dim">Cycles</td>
                  </tr>
                </thead>
                <tbody>
                  {ppfEntries.map(([f, n]) => (
                    <tr key={f}>
                      <td><code>{f}</code></td>
                      <td>{n}</td>
                    </tr>
                  ))}
                  <tr>
                    <td><b>Total</b></td>
                    <td><b className="ok">{cycles}</b></td>
                  </tr>
                </tbody>
              </table>
              <CyclesPerFeatureChart passesPerFeature={ppf} />
            </div>
          )}
          <div className="runchart-block runchart-block-wide" style={{ marginTop: 12 }}>
            <div className="runchart-head">Token burn</div>
            <TokenBurnTimeline events={events} startTs={startTs} />
          </div>
          {/* SET 9 / 12 — Cost block (hidden when pricing feed unavailable). */}
          {t1.cost && (t1.metrics.tokensIn != null || t1.metrics.tokensOut != null) && (
            <div style={{ marginTop: 12, borderTop: '1px solid var(--line)', paddingTop: 10 }}>
              <div className="cardhead"><b>Cost</b> <span className="dim small">estimated</span></div>
              <p className="small" style={{ margin: '4px 0 8px' }}>
                This run:{' '}
                <b className="ok">{t1.cost.label}</b>
                {t1.cost.kind === 'estimated' && t1.cost.usdIn != null && (
                  <span className="dim"> ({fmtUsd(t1.cost.usdIn)} in / {fmtUsd(t1.cost.usdOut)} out)</span>
                )}
                {t1.cost.kind === 'local' && (
                  <span className="dim"> — excludes electricity</span>
                )}
              </p>
              {(t1.cost.comparative?.length ?? 0) > 0 && (
                <>
                  <p className="dim small" style={{ margin: '0 0 4px' }}>This run would have cost…</p>
                  <table className="authtable" style={{ fontSize: 12 }}>
                    <thead>
                      <tr>
                        <td className="dim">family</td>
                        <td className="dim">model</td>
                        <td className="dim">total</td>
                        <td className="dim">in / out</td>
                      </tr>
                    </thead>
                    <tbody>
                      {(t1.cost.comparative ?? []).map(row => (
                        <tr key={row.id}>
                          <td>{row.label}</td>
                          <td><code style={{ fontSize: 11 }}>{row.id}</code></td>
                          <td>{fmtUsd(row.usdTotal)}</td>
                          <td className="dim">{fmtUsd(row.usdIn)} / {fmtUsd(row.usdOut)}</td>
                        </tr>
                      ))}
                    </tbody>
                  </table>
                </>
              )}
              <p className="dim small" style={{ margin: '8px 0 0' }}>
                {t1.cost.provenance}
                {' '}· excludes prompt-caching/batch discounts
              </p>
            </div>
          )}
        </div>
        );
      })()}

      {/* SET 56b S7 / R10 — this project only (install GLOBAL → AdminView). */}
      {projectStats && (
        <div className="card" style={{ marginBottom: 14 }}>
          <div className="cardhead"><b>Project &amp; install metrics</b></div>
          <div>
            <div className="cardhead">
              <b style={{ fontSize: 12.5 }}>this project</b>
              <span className="dim small">
                {projectStats.liveEpochInProgress && projectStats.totalsRuns <= 1
                  ? 'epoch in progress'
                  : `${projectStats.totalsRuns} epoch${projectStats.totalsRuns === 1 ? '' : 's'} in this project`}
                {projectStats.liveEpochInProgress && projectStats.totalsRuns > 1
                  ? ' · epoch in progress'
                  : ''}
              </span>
            </div>
            <MetricsGridTable
              rows={buildMetricsGrid(projectStats)}
              columns={['avg', 'min', 'max', 'lifetime']}
              labels={{ lifetime: 'Project total' }}
            />
          </div>
        </div>
      )}

      {t1?.counterfactual && (
        <div className="card" style={{ marginBottom: 14, borderColor: 'var(--frost)' }}>
          <div className="cardhead"><b>Substrate counterfactual</b> <span className="dim small">dry-run · 0 tokens</span></div>
          <p className="small" style={{ margin: '6px 0 0' }}>{t1.counterfactual.summary}</p>
        </div>
      )}

      {/* Prompt Log — every submission to this project's queue, demarcated epochs. */}
      {epochs.length > 0 && (
        <details
          className="eventlog card"
          style={{ marginBottom: 14 }}
          open={promptLogOpen}
          onToggle={e => {
            const open = (e.target as HTMLDetailsElement).open;
            setPromptLogOpen(open);
            writeCollapseOpen(COLLAPSE_PROMPT_KEY, open);
          }}
        >
          <summary><b>Prompt log</b> — {epochs.length} epoch{epochs.length === 1 ? '' : 's'}</summary>
          <div className="logbody" style={{ maxHeight: '38vh', overflow: 'auto', marginTop: 8 }}>
            {epochs.map((ep, epIdx) => (
              <div key={epIdx} style={{ marginBottom: epIdx < epochs.length - 1 ? 12 : 0 }}>
                <div className="logrow tone-accent" style={{ borderTop: epIdx ? '1px solid var(--line)' : undefined, paddingTop: epIdx ? 8 : 0 }}>
                  <span className="logts">#{epIdx + 1}</span>
                  <span className="loglabel">
                    {new Date(ep.ts).toLocaleString()}{ep.model ? ` · ${ep.model}` : ''}
                  </span>
                </div>
                {ep.prompt.split('\n').map((line, li) => (
                  <div key={li} className="logrow tone-neutral">
                    <span className="logts" style={{ minWidth: 30, textAlign: 'right' }}>{li + 1}</span>
                    <span className="loglabel" style={{ color: 'var(--ink)', whiteSpace: 'pre-wrap' }}>{line || '\u00a0'}</span>
                  </div>
                ))}
                <div style={{ marginTop: 8 }}>
                  <button
                    type="button"
                    className="ghostbtn"
                    onClick={() => downloadPromptMd(ep.id ?? t1?.id ?? null, ep.prompt)}
                  >
                    Download prompt
                  </button>
                </div>
              </div>
            ))}
          </div>
        </details>
      )}

      {reportOpen && (() => {
        const payload = bugPayload();
        if (!payload) return null;
        return (
          <div
            role="dialog"
            aria-modal="true"
            aria-label="Report a problem"
            style={{
              position: 'fixed', inset: 0, zIndex: 80,
              background: 'rgba(0,0,0,0.55)',
              display: 'flex', alignItems: 'center', justifyContent: 'center',
              padding: 16,
            }}
            onClick={e => { if (e.target === e.currentTarget && !reportBusy) setReportOpen(false); }}
          >
            <div className="card" style={{ maxWidth: 640, width: '100%', maxHeight: '90vh', overflow: 'auto' }}>
              <div className="cardhead">
                <b>Report a problem</b>
                <button type="button" className="ghostbtn" disabled={reportBusy} onClick={() => setReportOpen(false)}>Close</button>
              </div>
              <p className="dim small" style={{ margin: '0 0 10px' }}>
                Nothing is sent until you check the box and click Submit. See{' '}
                <a href="/privacy.html#bug-reports" target="_blank" rel="noreferrer">privacy · bug reports</a>.
              </p>
              <div className="cardhead"><b>Versions</b></div>
              <pre className="small" style={{ whiteSpace: 'pre-wrap', margin: '0 0 10px' }}>
                {JSON.stringify(payload.versions, null, 2)}
              </pre>
              <div className="cardhead"><b>Prompt</b></div>
              <pre className="small" style={{ whiteSpace: 'pre-wrap', maxHeight: 160, overflow: 'auto', margin: '0 0 10px' }}>
                {payload.prompt || '(empty)'}
              </pre>
              <div className="cardhead"><b>Events ({payload.events.length})</b></div>
              <pre className="small" style={{ whiteSpace: 'pre-wrap', maxHeight: 180, overflow: 'auto', margin: '0 0 10px' }}>
                {JSON.stringify(payload.events, null, 2)}
              </pre>
              <div className="cardhead"><b>Problems</b></div>
              <pre className="small" style={{ whiteSpace: 'pre-wrap', margin: '0 0 10px' }}>
                {payload.problems.length ? payload.problems.join('\n') : '(none)'}
              </pre>
              <div className="cardhead"><b>Metrics</b></div>
              <pre className="small" style={{ whiteSpace: 'pre-wrap', maxHeight: 140, overflow: 'auto', margin: '0 0 10px' }}>
                {JSON.stringify(payload.metrics, null, 2)}
              </pre>
              <label className="small" style={{ display: 'block', marginBottom: 8 }}>
                Contact (optional)
                <input
                  type="text"
                  value={reportContact}
                  onChange={e => setReportContact(e.target.value)}
                  disabled={reportBusy}
                  placeholder="email or handle"
                  style={{ display: 'block', width: '100%', marginTop: 4 }}
                />
              </label>
              <label className="small" style={{ display: 'flex', gap: 8, alignItems: 'flex-start', marginBottom: 10 }}>
                <input
                  type="checkbox"
                  checked={reportConsent}
                  disabled={reportBusy}
                  onChange={e => setReportConsent(e.target.checked)}
                  style={{ marginTop: 3 }}
                />
                <span>
                  I understand my prompt and this run&apos;s full log will be sent to the RHA maintainer,
                  used only to patch RHA, per the privacy policy.
                </span>
              </label>
              <div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
                <button
                  className="primary"
                  disabled={!reportConsent || reportBusy}
                  onClick={() => void submitBugReport()}
                >
                  {reportBusy ? 'Submitting…' : 'Submit report'}
                </button>
                {reportMsg && (
                  <span className="small" style={{ color: reportMsg.startsWith('Report submitted') ? 'var(--ok)' : 'var(--bad)' }}>
                    {reportMsg}
                  </span>
                )}
              </div>
            </div>
          </div>
        );
      })()}

      <div style={{ display: 'flex', gap: 8 }}>
        <button className="ghostbtn" onClick={onClose}>Back</button>
        {t1?.running && (
          <button
            className="clearall"
            title="The current LLM draw finishes, then the run stops and is saved as cancelled"
            onClick={() => {
              if (confirm('Cancel this job? The current draw finishes, then the run stops.')) onCancel();
            }}
          >
            Cancel Job
          </button>
        )}
      </div>
    </div>
  );
}

function readInstalledExt(): boolean {
  if (window.__RHA_EXT__?.version) return true;
  const raw = document.documentElement.dataset.rhaExt;
  if (raw) {
    try {
      const j = JSON.parse(raw) as { version?: string; extensionId?: string };
      if (j.version) {
        // Hydrate the messaging handle from the dataset: the content script's
        // postMessage fires at document_start, BEFORE React mounts its
        // listener — without this the extension id is never learned and
        // extSendMessage can't reach the extension.
        window.__RHA_EXT__ = { ...window.__RHA_EXT__, version: j.version, extensionId: j.extensionId };
        return true;
      }
    } catch { /* ignore */ }
  }
  return false;
}

type ExtStatus = 'unknown' | 'present' | 'outdated' | 'absent';

function cmpSemver(a: string, b: string): number {
  const pa = a.replace(/^v/i, '').split('.').map(n => parseInt(n, 10) || 0);
  const pb = b.replace(/^v/i, '').split('.').map(n => parseInt(n, 10) || 0);
  const len = Math.max(pa.length, pb.length);
  for (let i = 0; i < len; i++) {
    const d = (pa[i] ?? 0) - (pb[i] ?? 0);
    if (d) return d;
  }
  return 0;
}

/** SET 24e / 32e.ii — sidebar disposition colors, tone-mapped to the design
 *  system. `running` covers live in-progress snapshots (never a terminal
 *  disposition mid-run — the Runs → Failed regression). */
const WEBRUN_DISPOSITION_VAR: Record<RunOutcome, string> = {
  successful: 'var(--ok)',
  partial: 'var(--warn)',
  failed: 'var(--bad)',
  refused: 'var(--warn)',
  cancelled: 'var(--dim)',
  running: 'var(--warn)',
};

export function App() {
  const [jobs, setJobs] = useState<Job[]>([]);
  const [activeId, setActiveId] = useState<string | null>(null);
  const [prompt, setPrompt] = useState('');
  const [busy, setBusy] = useState(false);
  const [autopilot, setAutopilot] = useState(false);
  const [notifyNew, setNotifyNew] = useState(false);
  const [notifyDenied, setNotifyDenied] = useState(false);
  // SET 29c — notification authentication + invocation logging: every
  // attempt on either path (page / SW) is logged so a blocked or gated
  // notification reads as a reason instead of silence.
  const [notifyLog, setNotifyLog] = useState<string[]>([]);
  const [notifyTestBusy, setNotifyTestBusy] = useState(false);
  const [meta, setMeta] = useState<Meta | null>(null);
  const [extStatus, setExtStatus] = useState<ExtStatus>('unknown');
  const [extBannerDismissed, setExtBannerDismissed] = useState(false);
  const [controlOpen, setControlOpen] = useState(false);
  const [advancedMode, setAdvancedMode] = useState(false);
  const [supBanner, setSupBanner] = useState<string | null>(null);
  const [extRt, setExtRt] = useState<ExtRuntime | null>(null);
  const [t1, setT1] = useState<Tier1Status | null>(null);
  const [t1Open, setT1Open] = useState(false);
  const [adminOpen, setAdminOpen] = useState(false);
  // SET 33b.3 — "Your install" aggregate stats moved off Admin onto its own
  // sidebar-opened panel; mutually exclusive with admin/t1 (one main-area view at a time).
  const [installPanelOpen, setInstallPanelOpen] = useState(false);
  const [runMode, setRunMode] = useState<'rha' | 'vanilla'>('rha');
  const [webRuns, setWebRuns] = useState<{
    id: string; ts: string; title: string; problems: number | null;
    error?: string | null; prompt?: string | null; projectId?: string | null;
    parentId?: string | null; model?: string | null;
    status?: string | null;
  }[]>([]);
  // SET 56a — live truth from tier1:live (offscreen), never from stored records.
  const [liveId, setLiveId] = useState<string | null>(null);
  const [liveRunning, setLiveRunning] = useState(false);
  /** Which run the main pane is showing; null = follow live / latest status. */
  const [viewingRunId, setViewingRunId] = useState<string | null>(null);
  const liveIdRef = useRef<string | null>(null);
  const viewingRunIdRef = useRef<string | null>(null);
  liveIdRef.current = liveId;
  viewingRunIdRef.current = viewingRunId;
  // SET 50 / R12 — stale SPA bundle after Pages deploy.
  const [remoteBuildId, setRemoteBuildId] = useState<string | null>(null);
  const [dismissedRemoteBuildId, setDismissedRemoteBuildId] = useState<string | null>(null);
  const notifiedRef = useRef(new Set<string>()); // `${jobId}:${phase}`

  const refreshMeta = useCallback(async () => {
    try {
      const m = await api<Meta>('/api/meta');
      setMeta(m);
      if (m.providers?.advancedMode != null) setAdvancedMode(!!m.providers.advancedMode);
      return m;
    } catch { return null; }
  }, []);

  useEffect(() => {
    void refreshMeta().then(m => {
      if (m?.autopilotDefault) setAutopilot(true);
    });
  }, [refreshMeta]);

  // SET 50 / R12 — poll /version.json; silent on network failure.
  useEffect(() => {
    let stop = false;
    const tick = async () => {
      try {
        const r = await fetch('/version.json', { cache: 'no-store' });
        if (!r.ok || stop) return;
        const parsed = parseWebVersion(await r.json());
        if (!stop && parsed) setRemoteBuildId(parsed.buildId);
      } catch {
        /* silent — no network noise */
      }
    };
    void tick();
    const t = setInterval(() => { void tick(); }, STALE_BUNDLE_POLL_MS);
    return () => { stop = true; clearInterval(t); };
  }, []);

  // Meta polls so badge reflects provider Set Active without a full reload.
  useEffect(() => {
    const t = setInterval(() => void refreshMeta(), 2500);
    return () => clearInterval(t);
  }, [refreshMeta]);

  // Part T: presence + version skew (outdated still counts as present/operational).
  useEffect(() => {
    let cancelled = false;
    let sawInstalled = false;
    let bundledVersion: string | null = null;

    // Version truth comes from THIS origin — the distribution point. Hosted:
    // the statically published meta; local: the engine's route. Never via
    // API_BASE (the hosted site would ask the user's local engine, whose
    // cached copy can be older than what production serves).
    void fetch('/api/extension/meta')
      .then(r => (r.ok ? (r.json() as Promise<{ version?: string }>) : null))
      .then(m => { bundledVersion = m?.version ?? null; })
      .catch(() => { bundledVersion = null; });

    const evaluate = (allowAbsent: boolean) => {
      if (cancelled) return;
      const installed = window.__RHA_EXT__?.version;
      if (installed) {
        sawInstalled = true;
        if (bundledVersion && cmpSemver(installed, bundledVersion) < 0) {
          setExtStatus('outdated');
        } else {
          setExtStatus('present');
        }
        return;
      }
      if (readInstalledExt()) {
        sawInstalled = true;
        setExtStatus('present');
        return;
      }
      if (allowAbsent && !sawInstalled) setExtStatus('absent');
    };

    const onMsg = (ev: MessageEvent) => {
      const d = ev.data as { type?: string; source?: string; version?: string; hash?: string; extensionId?: string } | null;
      if (!d || d.type !== 'rha-ext-meta' || d.source !== 'rha-provider-vault' || !d.version) return;
      window.__RHA_EXT__ = { version: d.version, hash: d.hash, extensionId: d.extensionId };
      evaluate(false);
    };
    window.addEventListener('message', onMsg);

    evaluate(false);
    const t0 = Date.now();
    const poll = setInterval(() => {
      const timedOut = Date.now() - t0 > 2500;
      evaluate(timedOut);
      if (timedOut) clearInterval(poll);
    }, 400);

    return () => {
      cancelled = true;
      window.removeEventListener('message', onMsg);
      clearInterval(poll);
    };
  }, []);

  // Web runtime: when the extension is present, poll its state so the app
  // works with NO local engine — the extension is the runtime then.
  useEffect(() => {
    if (extStatus !== 'present' && extStatus !== 'outdated') return;
    let stop = false;
    const tick = () => {
      void extSendMessage<ExtRuntime>({ action: 'tier1:meta' })
        .then(r => {
          if (stop) return;
          setExtRt(r);
          if (r?.vaultVersion) {
            window.__RHA_EXT__ = {
              ...(window.__RHA_EXT__ || { version: r.vaultVersion }),
              version: r.vaultVersion,
            };
          }
        })
        .catch(() => { if (!stop) setExtRt(null); });
      // SET 56a — piggyback live truth on this existing timer (no new timer).
      void extSendMessage<{ liveId: string | null; running: boolean }>({ action: 'tier1:live' })
        .then(r => {
          if (stop) return;
          setLiveId(r?.liveId ?? null);
          setLiveRunning(!!r?.running);
        })
        .catch(() => { /* older vault — no tier1:live yet */ });
      void extSendMessage<{ runs: typeof webRuns }>({ action: 'tier1:runs' })
        .then(r => { if (!stop) setWebRuns(r.runs ?? []); })
        .catch(() => { /* older extension build — no run persistence yet */ });
    };
    tick();
    const t = setInterval(tick, 2500);
    return () => { stop = true; clearInterval(t); };
  }, [extStatus]);

  // A run is active (started from the site OR the popup): open the live view.
  useEffect(() => {
    if (liveRunning || extRt?.running) {
      setAdminOpen(false);
      setInstallPanelOpen(false);
      setT1Open(true);
      // Bind the pane to the live job when one appears.
      if (liveId) setViewingRunId(liveId);
    }
  }, [liveRunning, extRt?.running, liveId]);

  // Live event feed while a web-runtime compile is on screen.
  // SET 56a — same cadence also fetches tier1:live; status updates apply ONLY
  // when the pane is bound to the live id (or nothing selected yet).
  useEffect(() => {
    if (!t1Open) return;
    let stop = false;
    const tick = () => {
      void extSendMessage<{ liveId: string | null; running: boolean }>({ action: 'tier1:live' })
        .then(r => {
          if (stop) return;
          setLiveId(r?.liveId ?? null);
          setLiveRunning(!!r?.running);
        })
        .catch(() => { /* ignore */ });
      void extSendMessage<Tier1Status>({ action: 'tier1:status' })
        .then(r => {
          if (stop) return;
          const viewId = viewingRunIdRef.current;
          const lid = liveIdRef.current;
          // Live panel binds to liveId; non-live selections render from record only.
          if (viewId != null && lid != null && viewId !== lid) return;
          if (viewId != null && lid == null && viewId !== r?.id) return;
          setT1(r);
        })
        .catch(() => { /* popup/SW churn — keep last state */ });
    };
    tick();
    const t = setInterval(tick, 1000);
    return () => { stop = true; clearInterval(t); };
  }, [t1Open]);

  const refresh = useCallback(async () => {
    setJobs(await api<Job[]>('/api/jobs'));
  }, []);

  useEffect(() => { void refresh(); }, [refresh]);

  // Polling fallback: the sandbox runners are synchronous child processes, so
  // SSE can flush late during heavy phases — a slow poll keeps the UI honest.
  useEffect(() => {
    const t = setInterval(() => void refresh(), 2500);
    return () => clearInterval(t);
  }, [refresh]);

  // Browser notifications: every pipeline stage switch notifies; gates and
  // terminal phases (the user is being prompted) notify even with the tab
  // focused. Routine switches only fire while the tab is hidden. SET 29c —
  // every attempt is logged (fired / skipped(reason) / error) so a blocked
  // OS notification or the hidden-tab gate reads as a reason, not silence.
  useEffect(() => {
    for (const j of jobs) {
      const key = `${j.id}:${j.phase}`;
      if (notifiedRef.current.has(key)) continue;
      notifiedRef.current.add(key);
      const prompting = NOTIFY_PHASES.has(j.phase) || j.phase.startsWith('awaiting-');
      const { log } = attemptPageNotify(
        `RHA Wrapper — ${jobDisplayTitle(j)}`,
        { body: prompting ? `⚠ input needed: ${notifyBody(j)}` : notifyBody(j), tag: key },
        { bypassHiddenGate: prompting },
      );
      pushNotifyLog(log);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps -- pushNotifyLog is a stable setState wrapper
  }, [jobs]);

  // Ask for notification permission on the FIRST user gesture (a bare
  // page-load request is auto-denied by modern browsers).
  useEffect(() => {
    if (typeof Notification === 'undefined' || Notification.permission !== 'default') return;
    const ask = () => { void Notification.requestPermission(); };
    window.addEventListener('click', ask, { once: true });
    return () => window.removeEventListener('click', ask);
  }, []);

  const active = jobs.find(j => j.id === activeId) ?? null;

  // ANY process underway anywhere — engine job computing (gate-waits are user
  // decisions, not processes) or a web-runtime compile — locks new submissions.
  const TERMINAL = ['complete', 'failed', 'interrupted', 'insufficient-input'];
  const anyBusy = !!liveRunning || !!extRt?.running
    || jobs.some(j => !TERMINAL.includes(j.phase) && !j.phase.startsWith('awaiting-'));

  const onNotifyNewChange = async (on: boolean) => {
    setNotifyDenied(false);
    if (!on) { setNotifyNew(false); return; }
    if (typeof Notification === 'undefined') {
      setNotifyDenied(true);
      setNotifyNew(false);
      return;
    }
    let perm = Notification.permission;
    if (perm === 'default') perm = await Notification.requestPermission();
    if (perm !== 'granted') {
      setNotifyDenied(true);
      setNotifyNew(false);
      return;
    }
    setNotifyNew(true);
  };

  const pushNotifyLog = (line: string) => setNotifyLog(prev => [line, ...prev].slice(0, 30));

  // SET 29c — Test notification button: fires BOTH paths immediately,
  // bypassing the hidden-tab gate, so OS-level blocking (or the gate itself)
  // shows up as a logged reason instead of nothing happening.
  const runNotifyTest = async () => {
    setNotifyTestBusy(true);
    const lines: string[] = [];
    const page = attemptPageNotify(
      'RHA Wrapper — test notification',
      { body: 'Page path (Web Notifications API) — this fired from the open tab.' },
      { bypassHiddenGate: true },
    );
    lines.push(page.log);
    if (extStatus === 'present' || extStatus === 'outdated') {
      try {
        const r = await extSendMessage<{ id?: string; error?: string }>({
          action: 'tier1:notify',
          title: 'Test notification',
          message: 'SW path — this fired from the extension service worker.',
        });
        lines.push(formatSwNotifyLog(r));
      } catch (e) {
        lines.push(formatSwNotifyLog({ error: e instanceof Error ? e.message : String(e) }));
      }
    } else {
      lines.push('notify(sw): skipped(extension not installed)');
    }
    setNotifyLog(prev => [...lines, ...prev].slice(0, 30));
    setNotifyTestBusy(false);
  };

  const createJob = async () => {
    if (!prompt.trim()) return;
    // Web runtime: no engine — compile inside the extension.
    if (!meta && extRt?.active && extRt.unlocked) {
      setBusy(true);
      try {
        await extSendMessage({
          action: 'tier1:run',
          payload: { prompt, mode: runMode },
        });
        setPrompt('');
        setT1(null);
        setViewingRunId(null); // follow live via status until liveId arrives
        setAdminOpen(false);
        setInstallPanelOpen(false);
        setT1Open(true);
      } catch (e) {
        alert(e instanceof Error ? e.message : String(e));
      } finally {
        setBusy(false);
      }
      return;
    }
    setBusy(true);
    try {
      const job = await api<Job>('/api/jobs', { method: 'POST', body: JSON.stringify({ prompt, autopilot }) });
      if (notifyNew) setNotifyEnabled(job.id, true);
      setPrompt('');
      await refresh();
      setActiveId(job.id);
    } catch (e) {
      alert(e instanceof Error ? e.message : String(e));
    } finally {
      setBusy(false);
    }
  };

  const showExtBanner = !extBannerDismissed && (extStatus === 'absent' || extStatus === 'outdated');
  // SET 23b — app build version vs engine /api/meta version (stale client missing fields).
  const appBundleStale = !!(meta?.version && meta.version !== APP_VERSION);
  // SET 50 / R12 — long-lived SPA tab holding a dead bundle after Pages deploy.
  const showStaleBundle = shouldShowStaleBundleBanner({
    selfBuildId: WEB_BUILD_ID,
    remoteBuildId,
    dismissedRemoteBuildId,
  });
  // SET 47d / R11 — vault label from the LIVE extension (meta/status).
  const liveVaultVersion = extRt?.vaultVersion || t1?.vaultVersion || window.__RHA_EXT__?.version || null;
  const brandInner = (
    <>
      <img className="brandmark" src="/logo.svg" alt="RHA Wrapper" width="42" height="42" />
      <h1>
        RHA<span>Wrapper</span>
        {meta?.legacy ? <>{' '}<em className="legacy">Legacy</em></> : null}
      </h1>
    </>
  );
  const aboutHref = meta?.canonical ? '/landing.html' : 'https://rha.software/';
  const privacyHref = meta?.canonical ? '/privacy.html' : 'https://rha.software/privacy.html';
  const external = !meta?.canonical;
  const connectedModel = !!(meta?.connected && meta.model);
  const opProv = meta?.providers?.operators?.[0] ?? meta?.providers?.operator;
  const op2 = meta?.providers?.operators?.[1];
  const supProv = meta?.providers?.supervisor;
  const showTwoAgents = !!(advancedMode && supProv?.connected && opProv?.connected);

  return (
    <div className="shell">
      <aside className="side">
        {/* SET 53 / 47f — brand always links home/about, same-tab only. */}
        <a className="brand" href={aboutHref} title="RHA Wrapper — about">
          {brandInner}
        </a>
        <p className="tag">prompt → frozen IR → build → RHA. Deterministically verified.</p>
        {showExtBanner && (
          <div className="extbanner" role="status">
            <span>
              {extStatus === 'outdated' ? (
                <>
                  A newer Provider Vault is available —{' '}
                  <a href="/api/extension.zip" download>Download</a>
                  {' '}→ reload at chrome://extensions. (Still detected — non-blocking.)
                </>
              ) : (
                <>
                  Provider Vault extension not detected —{' '}
                  <a href="/api/extension.zip" download>Download</a>
                  {' '}→ then load it at chrome://extensions.
                </>
              )}
            </span>
            <button type="button" className="extbanner-x" aria-label="Dismiss" onClick={() => setExtBannerDismissed(true)}>×</button>
          </div>
        )}
        {appBundleStale && (
          <div className="extbanner" role="status">
            <span>
              App bundle is stale (UI {APP_VERSION}, engine {meta?.version}) — hard-reload this page
              {meta?.canonical ? ' after npm run build:web' : ''}.
            </span>
          </div>
        )}
        {showStaleBundle && (
          <div className="stalebanner" role="status">
            <span>
              {STALE_BUNDLE_BANNER}
              {' — '}
              <button
                type="button"
                className="stalebanner-reload"
                onClick={() => { window.location.reload(); }}
              >
                Reload
              </button>
            </span>
            <button
              type="button"
              className="stalebanner-x"
              aria-label="Dismiss"
              onClick={() => setDismissedRemoteBuildId(remoteBuildId)}
            >
              ×
            </button>
          </div>
        )}
        {meta?.mock && (
          <div className="mockbadge">
            MOCK LLM — your prompt is ignored; every job runs the built-in fixture
            (URL Shortener Library with 3 injected bugs). Start the
            <code> rha-wrapper-live</code> server for real generation.
          </div>
        )}
        {!meta?.mock && showTwoAgents && (
          <div className="rolebadges">
            <div className="supbadge" title="Supervisor — encode / IR / escalations">
              supervisor · {supProv!.label || supProv!.model} · {supProv!.kind}
            </div>
            <div className="opbadge" title="Operator — build / repair">
              operator · {opProv!.label || opProv!.model} · {opProv!.kind}
              {op2?.connected ? ` + ${op2.label || op2.model}` : ''}
            </div>
          </div>
        )}
        {!meta?.mock && !showTwoAgents && connectedModel && meta?.kind === 'local' && (
          <div className="localbadge">local · {meta.model}</div>
        )}
        {!meta?.mock && !showTwoAgents && connectedModel && meta?.kind !== 'local' && (
          <div className="livebadge">cloud · {meta!.model}</div>
        )}
        {!meta && extRt?.active && extRt.unlocked && (
          <div className="localbadge">
            web · {extRt.active.model || extRt.active.name} · extension runtime
          </div>
        )}
        {!meta && extRt && !extRt.unlocked && (
          <div className="dimbadge">vault locked — open Provider Vault and unlock it</div>
        )}
        {!meta && extRt && extRt.unlocked && !extRt.active && (
          <div className="dimbadge">no provider assigned — open Provider Vault → Assign role</div>
        )}
        {!meta?.mock && !connectedModel && !(extRt && (!meta)) && (
          <div className="dimbadge">no model connected — open Provider Vault → Set Active</div>
        )}
        {supBanner && (
          <div className="supbanner" role="status">
            {supBanner}
            <button type="button" className="extbanner-x" aria-label="Dismiss" onClick={() => setSupBanner(null)}>×</button>
          </div>
        )}
        <textarea
          data-rha-boot="control-prompt"
          value={prompt}
          placeholder={anyBusy
            ? 'Locked — a process is underway. New submissions unlock when it finishes.'
            : 'Paste your feature spec (author it in your LLM of choice — ChatGPT, Claude, …). One feature per line: observable behavior + edge cases. V1 builds logic, not design.'}
          disabled={anyBusy}
          style={anyBusy ? { opacity: 0.45 } : undefined}
          onChange={e => setPrompt(e.target.value)}
        />
        <button type="button" className="ghostbtn ctrlbtn" onClick={() => setControlOpen(true)}>
          Control Panel
        </button>
        {!meta && extRt?.active && (
          <label
            className="autochk"
            title="One prompt → one model call producing implementation + the model's own tests. No plan, gates, convergence, or harness contract."
          >
            <input
              type="checkbox"
              checked={runMode === 'vanilla'}
              onChange={e => setRunMode(e.target.checked ? 'vanilla' : 'rha')}
            />
            Vanilla (single draw — no scaffolding, no repair)
          </label>
        )}
        <label className="autochk" title="No hard stops: input gate, IR approval and build approval become recorded ledger decisions; escalations ride to the final report for post-hoc authentication.">
          <input type="checkbox" checked={autopilot} onChange={e => setAutopilot(e.target.checked)} />
          ⚡ Unsupervised E2E — no approval gates
        </label>
        <label className="autochk" title="OS notification when this run hits a gate or finishes — only while this tab is in the background.">
          <input
            type="checkbox"
            checked={notifyNew}
            onChange={e => void onNotifyNewChange(e.target.checked)}
          />
          🔔 Browser notifications
        </label>
        <button type="button" className="ghostbtn" disabled={notifyTestBusy} onClick={() => void runNotifyTest()}>
          {notifyTestBusy ? 'Testing…' : 'Test notification'}
        </button>
        {notifyDenied && (
          <p className="notifydeny dim small">Notifications blocked in this browser — enable them in site settings to use this.</p>
        )}
        {notifyLog.length > 0 && (
          <details className="dim small" style={{ width: '100%', margin: '2px 0 0' }}>
            <summary>Notification log — {notifyLog.length} attempt{notifyLog.length === 1 ? '' : 's'}</summary>
            <p className="dim small" style={{ margin: '4px 0' }}>
              macOS: Chrome must also be allowed in System Settings → Notifications, or every attempt above
              still logs but nothing appears on screen.
            </p>
            <pre className="small" style={{ whiteSpace: 'pre-wrap', margin: 0 }}>{notifyLog.join('\n')}</pre>
          </details>
        )}
        <button
          data-rha-boot="control-compile"
          className="primary"
          disabled={busy || anyBusy || !prompt.trim() || (!connectedModel && !meta?.mock && !(!meta && extRt?.active && extRt.unlocked))}
          style={anyBusy ? { opacity: 0.45 } : undefined}
          title={anyBusy ? 'A process is underway — new submissions unlock when it finishes' : undefined}
          onClick={() => void createJob()}
        >
          {busy ? 'Encoding…' : anyBusy ? 'Process underway…' : 'Compile'}
        </button>
        <ExtensionGuide status={extStatus} />
        {!meta?.canonical && (
          <AttachProject onAttached={async id => { await refresh(); setActiveId(id); }} />
        )}
        <JobList
          jobs={jobs}
          activeId={activeId}
          onSelect={setActiveId}
          onChanged={refresh}
        />
        {(webRuns.length > 0 || liveRunning || extRt?.running) && (
          <div className="webruns">
            <h3 className="sechead" style={{ margin: '10px 0 4px' }}>Web runs</h3>
            {webRuns.length > 0 && (() => {
              const counts: Record<RunOutcome, number> = { successful: 0, partial: 0, failed: 0, refused: 0, cancelled: 0, running: 0 };
              for (const r of webRuns) counts[classifyRunOutcome(r)] += 1;
              // SET 33b.3 — this summary line IS the entry point into "Your
              // install" aggregate stats (moved off Admin; local-only panel).
              return (
                <button
                  type="button"
                  className="ghostbtn"
                  style={{
                    display: 'block', width: '100%', textAlign: 'left', margin: '0 0 6px',
                    fontSize: 12, background: 'none', border: 'none', padding: 0, fontWeight: 400,
                  }}
                  title="Open your install&#8217;s aggregate stats (local only)"
                  onClick={() => { setAdminOpen(false); setT1Open(false); setInstallPanelOpen(true); }}
                >
                  <span className="dim">
                    {webRuns.length} run{webRuns.length === 1 ? '' : 's'}
                    {' — '}
                    <span style={{ color: WEBRUN_DISPOSITION_VAR.successful }}>{counts.successful} clean</span>
                    {' · '}
                    <span style={{ color: WEBRUN_DISPOSITION_VAR.partial }}>{counts.partial} partial</span>
                    {' · '}
                    <span style={{ color: WEBRUN_DISPOSITION_VAR.failed }}>{counts.failed} failed</span>
                    {' · '}
                    <span style={{ color: WEBRUN_DISPOSITION_VAR.cancelled }}>{counts.cancelled} cancelled</span>
                  </span>
                </button>
              );
            })()}
            {liveRunning && liveId && (
              <button
                type="button"
                className="ghostbtn"
                style={{ display: 'block', width: '100%', textAlign: 'left', marginBottom: 4, fontSize: 12 }}
                onClick={() => {
                  setViewingRunId(liveId);
                  setAdminOpen(false);
                  setInstallPanelOpen(false);
                  setT1Open(true);
                }}
              >
                ⟳ compiling now…
                <span style={{ color: 'var(--dim)', marginLeft: 6 }}>click to watch</span>
              </button>
            )}
            {(() => {
              // Group runs into project queues: root run founds a project;
              // continuations stack beneath it, oldest first (submission order).
              const groups = new Map<string, typeof webRuns>();
              for (const r of webRuns) {
                const k = r.projectId || r.id;
                groups.set(k, [...(groups.get(k) ?? []), r]);
              }
              // SET 56a — "(running)" chip IFF entry.id === liveId.
              // SET 56b S4 — title / toLocaleString ts / status chip.
              const sidebarLabel = (r: (typeof webRuns)[number]): string => {
                if (r.id === liveId) return 'running';
                if (r.status === 'interrupted') return 'interrupted';
                if (r.status === 'cancelled') return 'cancelled';
                if (r.status === 'complete') return 'complete';
                if (r.status === 'failed') return 'failed';
                const o = classifyRunOutcome({
                  ...r,
                  running: false,
                  live: false,
                  status: r.status === 'running' ? null : r.status,
                });
                if (o === 'successful') return 'complete';
                if (o === 'running') return 'interrupted';
                return o;
              };
              const sidebarColor = (r: (typeof webRuns)[number]): string => {
                const label = sidebarLabel(r);
                if (label === 'running') return WEBRUN_DISPOSITION_VAR.running;
                if (label === 'complete') return WEBRUN_DISPOSITION_VAR.successful;
                if (label === 'interrupted' || label === 'failed') return WEBRUN_DISPOSITION_VAR.failed;
                if (label === 'cancelled') return WEBRUN_DISPOSITION_VAR.cancelled;
                return WEBRUN_DISPOSITION_VAR.partial;
              };
              const runTitle = (r: (typeof webRuns)[number]): string => {
                const line = (r.prompt || r.title || '').split('\n')[0].trim();
                return line.length > 40 ? `${line.slice(0, 40)}…` : line;
              };
              return [...groups.entries()].map(([pid, runs]) => {
                const queue = [...runs].reverse(); // index is newest-first
                return (
                  <div key={pid} style={{ marginBottom: 8 }}>
                    <div style={{ fontSize: 11, color: 'var(--dim)', margin: '2px 0' }}>
                      {queue[0].title}{queue.length > 1 ? ` — ${queue.length} runs` : ''}
                    </div>
                    {queue.map((r) => (
                      <button
                        key={r.id}
                        type="button"
                        className="ghostbtn"
                        title={r.prompt || ''}
                        style={{ display: 'block', width: '100%', textAlign: 'left', marginBottom: 3, fontSize: 12 }}
                        onClick={() => {
                          setAdminOpen(false);
                          setInstallPanelOpen(false);
                          setT1Open(true);
                          setViewingRunId(r.id);
                          // SET 56a — live pane binds to liveId; other runs → tier1:record only.
                          if (r.id === liveId) {
                            setT1(null); // status poll will fill
                            return;
                          }
                          void extSendMessage<{
                            id?: string;
                            ts?: string;
                            model?: string | null;
                            events?: Tier1Status['events'];
                            mode?: string | null;
                            problems?: string[] | null;
                            error?: string | null;
                            metrics?: Tier1Metrics | null;
                            cost?: RunCostSnapshot | null;
                            projectId?: string | null;
                            status?: string | null;
                            checkpointAt?: string | null;
                            finalized?: boolean;
                            running?: boolean;
                            prompt?: string | null;
                            contractVersion?: string | null;
                            errorMsg?: string;
                          }>({ action: 'tier1:record', runId: r.id })
                            .then(rec => {
                              if ((rec as { error?: string }).error) {
                                throw new Error((rec as { error: string }).error);
                              }
                              setT1(tier1StatusFromRecord({ ...rec, prompt: rec.prompt ?? r.prompt }));
                            })
                            .catch(e => alert(e instanceof Error ? e.message : String(e)));
                        }}
                      >
                        {runTitle(r)}
                        <span style={{ color: 'var(--dim)', marginLeft: 6 }}>
                          {new Date(r.ts).toLocaleString()}
                          {' · '}
                          <span style={{ color: sidebarColor(r) }}>
                            {sidebarLabel(r)}
                          </span>
                        </span>
                      </button>
                    ))}
                  </div>
                );
              });
            })()}
          </div>
        )}
        <ShareButton />
        <div className="footlinks">
          <a href={aboutHref} {...(external ? { target: '_blank', rel: 'noreferrer' } : {})}>about</a>
          <span>·</span>
          <a href={privacyHref} {...(external ? { target: '_blank', rel: 'noreferrer' } : {})}>privacy</a>
          <span>·</span>
          <a
            href="#admin"
            onClick={e => {
              e.preventDefault();
              setAdminOpen(true);
              setT1Open(false);
              setInstallPanelOpen(false);
            }}
          >admin</a>
          <span>·</span>
          <a href="https://polyformproject.org/licenses/shield/1.0.0" target="_blank" rel="noreferrer">PolyForm Shield 1.0.0</a>
          {meta?.version ? (
            <><span>·</span><span className="footver">{meta.version}</span></>
          ) : liveVaultVersion ? (
            <><span>·</span><span className="footver" title="Provider Vault extension version">vault {liveVaultVersion}</span></>
          ) : null}
        </div>
      </aside>
      <main className="main">
        {installPanelOpen ? (
          <InstallStatsPanel
            extSendMessage={extSendMessage}
            extPresent={extStatus === 'present' || extStatus === 'outdated'}
            onClose={() => setInstallPanelOpen(false)}
          />
        ) : adminOpen ? (
          <AdminView
            // Engine section only renders when a local engine actually answered
            // /api/meta — never a guessed/always-on link (SET 33b.1).
            engineAdminHref={meta ? `${API_BASE || 'http://localhost:4100'}/admin` : undefined}
            onClose={() => setAdminOpen(false)}
          />
        ) : t1Open ? (
          <Tier1View
            t1={t1}
            epochs={(() => {
              const pid = t1?.projectId || t1?.id;
              if (!pid) return t1?.prompt ? [{ ts: new Date().toISOString(), model: t1.model, prompt: t1.prompt }] : [];
              const chain = webRuns
                .filter(r => (r.projectId || r.id) === pid && r.prompt)
                .slice().reverse(); // index is newest-first
              const eps = chain.map(r => ({ id: r.id as string | null | undefined, ts: r.ts, model: r.model, prompt: r.prompt! }));
              if (t1?.prompt && !chain.some(r => r.id === t1.id)) {
                eps.push({ id: t1.id ?? null, ts: t1.events?.[0]?.ts ?? new Date().toISOString(), model: t1.model, prompt: t1.prompt });
              }
              return eps;
            })()}
            pipelineEpochs={(() => {
              const pid = t1?.projectId || t1?.id;
              if (!pid) {
                return t1?.id
                  ? [{ id: t1.id, ts: t1.events?.[0]?.ts ?? new Date().toISOString(), model: t1.model, isCurrent: true }]
                  : [];
              }
              const chain = webRuns
                .filter(r => (r.projectId || r.id) === pid)
                .slice().reverse(); // oldest first
              const eps: Tier1PipelineEpoch[] = chain.map(r => ({
                id: r.id,
                ts: r.ts,
                model: r.model,
                isCurrent: !!t1?.id && r.id === t1.id,
              }));
              if (t1?.id && !eps.some(e => e.id === t1.id)) {
                eps.push({
                  id: t1.id,
                  ts: t1.events?.[0]?.ts ?? new Date().toISOString(),
                  model: t1.model,
                  isCurrent: true,
                });
              }
              return eps;
            })()}
            busy={anyBusy}
            onExport={() => void extSendMessage({ action: 'tier1:export' }).catch(e =>
              alert(e instanceof Error ? e.message : String(e)))}
            onContinue={text => void extSendMessage({
              action: 'tier1:run',
              payload: { prompt: text, continueFrom: t1?.id, mode: runMode },
            }).then(() => setT1(null))
              .catch(e => alert(e instanceof Error ? e.message : String(e)))}
            onCancel={() => void extSendMessage({ action: 'tier1:cancel' }).catch(e =>
              alert(e instanceof Error ? e.message : String(e)))}
            onResume={() => void extSendMessage({ action: 'tier1:resume' }).catch(e =>
              alert(e instanceof Error ? e.message : String(e)))}
            onClose={() => setT1Open(false)}
          />
        ) : active ? <JobView job={active} onChange={refresh} /> : <Empty />}
      </main>
      {controlOpen && (
        <ControlPanel
          meta={meta}
          onClose={() => { setControlOpen(false); void refreshMeta(); }}
          onActivated={kind => {
            if (kind === 'supervisor') {
              setSupBanner('Supervisor is live — assign its provider in Provider Vault and pick behavior toggles in Control Panel.');
            } else if (kind === 'supervisor-off') {
              setSupBanner(null);
            }
          }}
        />
      )}
    </div>
  );
}

type SortMode = 'manual' | 'recent-desc' | 'recent-asc' | 'outcome-desc' | 'outcome-asc';

const LIST_PREFS_KEY = 'rha-joblist-prefs';
const SORT_OPTIONS: { value: SortMode; label: string }[] = [
  { value: 'manual', label: 'Manual' },
  { value: 'recent-desc', label: 'Recent ↓' },
  { value: 'recent-asc', label: 'Recent ↑' },
  { value: 'outcome-desc', label: 'Outcome ↓' },
  { value: 'outcome-asc', label: 'Outcome ↑' },
];

/** Split a boolean search query into required (+) and negated (−) terms; supports "quoted phrases". */
function parseSearchQuery(q: string): { need: string[]; not: string[] } {
  const need: string[] = [];
  const not: string[] = [];
  const re = /"([^"]+)"|(\S+)/g;
  let m: RegExpExecArray | null;
  while ((m = re.exec(q)) !== null) {
    const raw = (m[1] ?? m[2]).trim();
    if (!raw) continue;
    if (raw.startsWith('-') && raw.length > 1) not.push(raw.slice(1).toLowerCase());
    else need.push(raw.toLowerCase());
  }
  return { need, not };
}

function jobMatchesQuery(j: Job, q: string): boolean {
  if (!q.trim()) return true;
  const hay = `${j.title} ${j.prompt} ${j.id}`.toLowerCase();
  const { need, not } = parseSearchQuery(q);
  return need.every(t => hay.includes(t)) && not.every(t => !hay.includes(t));
}

/** True when a search term hits the rendered title (not only the body). */
function titleHit(j: Job, q: string): boolean {
  if (!q.trim()) return true;
  const title = jobDisplayTitle(j).toLowerCase();
  const { need } = parseSearchQuery(q);
  if (!need.length) return true;
  return need.every(t => title.includes(t));
}

/** Higher = better outcome for Outcome ↓. */
function outcomeRank(phase: string): number {
  switch (phase) {
    case 'complete': return 6;
    case 'awaiting-ir-approval':
    case 'awaiting-build-approval': return 5;
    case 'healing':
    case 'building': return 4;
    case 'generating-ir':
    case 'encoding':
    case 'created': return 3;
    case 'insufficient-input': return 2;
    case 'failed': return 1;
    default: return 0;
  }
}

function loadListPrefs(): { query: string; sort: SortMode } {
  try {
    const raw = localStorage.getItem(LIST_PREFS_KEY);
    if (!raw) return { query: '', sort: 'manual' };
    const p = JSON.parse(raw) as { query?: string; sort?: string };
    const sort = SORT_OPTIONS.some(o => o.value === p.sort) ? (p.sort as SortMode) : 'manual';
    return { query: typeof p.query === 'string' ? p.query : '', sort };
  } catch {
    return { query: '', sort: 'manual' };
  }
}

function JobList({ jobs, activeId, onSelect, onChanged }: {
  jobs: Job[];
  activeId: string | null;
  onSelect: (id: string | null) => void;
  onChanged: () => Promise<void>;
}) {
  const prefs0 = loadListPrefs();
  const [renaming, setRenaming] = useState<string | null>(null);
  const [draft, setDraft] = useState('');
  const [query, setQuery] = useState(prefs0.query);
  const [sort, setSort] = useState<SortMode>(prefs0.sort);
  const dragId = useRef<string | null>(null);

  useEffect(() => {
    localStorage.setItem(LIST_PREFS_KEY, JSON.stringify({ query, sort }));
  }, [query, sort]);

  const visible = useMemo(() => {
    const matched = jobs.filter(j => jobMatchesQuery(j, query));
    const byRecent = (a: Job, b: Job) => +new Date(b.createdAt) - +new Date(a.createdAt);
    const byManual = (a: Job, b: Job) =>
      (a.sortIndex ?? Number.MAX_SAFE_INTEGER) - (b.sortIndex ?? Number.MAX_SAFE_INTEGER)
      || byRecent(a, b);
    const byOutcomeDesc = (a: Job, b: Job) =>
      outcomeRank(b.phase) - outcomeRank(a.phase) || byRecent(a, b);
    const byOutcomeAsc = (a: Job, b: Job) =>
      outcomeRank(a.phase) - outcomeRank(b.phase) || byRecent(a, b);

    let cmp = byManual;
    if (sort === 'recent-desc') cmp = byRecent;
    else if (sort === 'recent-asc') cmp = (a, b) => -byRecent(a, b);
    else if (sort === 'outcome-desc') cmp = byOutcomeDesc;
    else if (sort === 'outcome-asc') cmp = byOutcomeAsc;

    // Title hits rank above body-only hits; within a tier, fall through to sort.
    return matched.slice().sort((a, b) => {
      const ta = titleHit(a, query) ? 1 : 0;
      const tb = titleHit(b, query) ? 1 : 0;
      if (tb !== ta) return tb - ta;
      return cmp(a, b);
    });
  }, [jobs, query, sort]);

  const del = async (j: Job) => {
    if (!confirm(`Delete "${jobDisplayTitle(j)}" and its workspace?`)) return;
    try {
      await api(`/api/jobs/${j.id}`, { method: 'DELETE' });
      if (activeId === j.id) onSelect(null);
      await onChanged();
    } catch (e) {
      alert(e instanceof Error ? e.message : String(e));
    }
  };

  const clearAll = async () => {
    if (!confirm(`Delete all ${jobs.length} runs and their workspaces? Active runs are kept.`)) return;
    await api('/api/jobs', { method: 'DELETE' });
    onSelect(null);
    await onChanged();
  };

  const commitRename = async (id: string) => {
    const title = draft.trim();
    setRenaming(null);
    if (!title) return;
    await api(`/api/jobs/${id}`, { method: 'PATCH', body: JSON.stringify({ title }) });
    await onChanged();
  };

  const dropOn = async (targetId: string) => {
    if (sort !== 'manual') return;
    const src = dragId.current;
    dragId.current = null;
    if (!src || src === targetId) return;
    // Reorder against the full saved list (search never mutates sortIndex).
    const ids = jobs
      .slice()
      .sort((a, b) => (a.sortIndex ?? Number.MAX_SAFE_INTEGER) - (b.sortIndex ?? Number.MAX_SAFE_INTEGER)
        || +new Date(b.createdAt) - +new Date(a.createdAt))
      .map(j => j.id);
    const from = ids.indexOf(src);
    const to = ids.indexOf(targetId);
    if (from < 0 || to < 0) return;
    ids.splice(to, 0, ...ids.splice(from, 1));
    await api('/api/jobs/order', { method: 'PUT', body: JSON.stringify({ ids }) });
    await onChanged();
  };

  const manual = sort === 'manual';

  return (
    <>
      <div className="listcontrols">
        <input
          className="jobsearch"
          type="search"
          value={query}
          placeholder='Search runs — wiki -failed "multi word"'
          onChange={e => setQuery(e.target.value)}
        />
        <select
          className="jobsort"
          value={sort}
          title="Sort runs"
          onChange={e => setSort(e.target.value as SortMode)}
        >
          {SORT_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
        </select>
      </div>
      {jobs.length > 0 && (
        <div className="listtools">
          <span className="dim small">
            {visible.length}/{jobs.length} run{jobs.length === 1 ? '' : 's'}
            {manual ? ' · drag to reorder' : ''} · double-click to rename
          </span>
          <button className="clearall" onClick={() => void clearAll()}>clear all</button>
        </div>
      )}
      <div className="joblist">
        {visible.map(j => (
          <div
            key={j.id}
            className={'jobitem' + (j.id === activeId ? ' active' : '')}
            draggable={manual && renaming !== j.id}
            onDragStart={() => { if (manual) dragId.current = j.id; }}
            onDragOver={e => { if (manual) e.preventDefault(); }}
            onDrop={() => void dropOn(j.id)}
            onClick={() => onSelect(j.id)}
          >
            {renaming === j.id ? (
              <input
                className="renamebox"
                value={draft}
                autoFocus
                onChange={e => setDraft(e.target.value)}
                onBlur={() => void commitRename(j.id)}
                onKeyDown={e => {
                  if (e.key === 'Enter') void commitRename(j.id);
                  if (e.key === 'Escape') setRenaming(null);
                }}
                onClick={e => e.stopPropagation()}
              />
            ) : (
              <strong
                onDoubleClick={e => {
                  e.stopPropagation();
                  setRenaming(j.id);
                  setDraft(j.title || j.prompt.slice(0, 40));
                }}
              >
                {jobDisplayTitle(j)}
              </strong>
            )}
            <div className="jobitem-foot">
              <button
                type="button"
                className={'jobflag' + (j.starred ? ' on' : '')}
                title={j.starred ? 'Unstar' : 'Star (bookmark)'}
                onClick={e => {
                  e.stopPropagation();
                  void api(`/api/jobs/${j.id}`, { method: 'PATCH', body: JSON.stringify({ starred: !j.starred }) }).then(() => onChanged());
                }}
              >★</button>
              <button
                type="button"
                className={'jobflag pin' + (j.pinned ? ' on' : '')}
                title={j.pinned ? 'Unpin project' : 'Pin as living project'}
                onClick={e => {
                  e.stopPropagation();
                  void api(`/api/jobs/${j.id}`, { method: 'PATCH', body: JSON.stringify({ pinned: !j.pinned }) }).then(() => onChanged());
                }}
              >📌</button>
              <em className={'phase p-' + j.phase}>
                {gateChip(j.phase) ?? phaseLabel(j.phase)}
              </em>
              <span className="jobts">{fmtTime(j.createdAt)}</span>
              <button
                className="jobdel"
                title="Delete this run and its workspace"
                onClick={e => { e.stopPropagation(); void del(j); }}
              >×</button>
            </div>
          </div>
        ))}
      </div>
    </>
  );
}

function CopyChip({ text, label }: { text: string; label?: string }) {
  const [copied, setCopied] = useState(false);
  const copy = () => {
    void navigator.clipboard.writeText(text).then(() => {
      setCopied(true);
      setTimeout(() => setCopied(false), 1600);
    });
  };
  return (
    <button className="copychip" onClick={copy} title={`Click to copy ${text} — paste it into Chrome's address bar (Chrome blocks direct links to its own pages)`}>
      {label ?? text} {copied ? '✓ copied' : '⧉'}
    </button>
  );
}

function ExtensionGuide({ status }: { status: ExtStatus }) {
  const chip =
    status === 'present' ? (
      <p className="extstatus ok">✓ Provider Vault extension detected.</p>
    ) : status === 'outdated' ? (
      <p className="extstatus warn">⚠ Extension detected but outdated — download a newer build (still operational).</p>
    ) : status === 'absent' ? (
      <p className="extstatus bad">✗ Not detected — follow the steps below.</p>
    ) : (
      <p className="extstatus dim">Checking for the Provider Vault extension…</p>
    );
  return (
    <details className="extguide">
      <summary>🔐 Setup — install the extension FIRST</summary>
      {chip}
      <p className="extnote">
        The Provider Vault extension holds your keys (encrypted) and points this app at your model.
        Install it before compiling anything.
      </p>
      <ol className="extsteps">
        <li>
          <a href="/api/extension.zip" download>Download the extension (.zip)</a> and extract it
          somewhere permanent (Chrome loads it from that folder).
        </li>
        <li>
          Open <CopyChip text="chrome://extensions" /> in a new tab (click to copy, then paste —
          Chrome blocks direct links to its own pages).
        </li>
        <li>
          Enable <b>Developer mode</b> — the top-right switch at{' '}
          <CopyChip text="chrome://extensions" />.
        </li>
        <li>Click <b>Load unpacked</b> and select the extracted folder.</li>
        <li>Pin the RHA Wrapper icon, open it, and create your vault (password ≥ 8 chars).</li>
        <li>
          Add a provider: your Anthropic API key for Claude, or your LM&nbsp;Studio / Ollama URL
          (e.g. <code>http://localhost:1234/v1</code>) for a local model. Keys are AES-256-GCM
          encrypted on your machine and never leave it except to the provider you configured.
        </li>
        <li>Hit <b>Set Active</b> — this server hot-reloads onto that provider.</li>
        <li>
          Now compile: paste an <b>already-defined feature spec</b> into the box above — author it
          with your LLM of choice (ChatGPT, Claude, …), one feature per line with observable
          behavior and edge cases. You don&apos;t describe an idea here; you paste a spec.
        </li>
      </ol>
    </details>
  );
}

function AttachProject({ onAttached }: { onAttached: (id: string) => Promise<void> }) {
  const [folder, setFolder] = useState('');
  const [busy, setBusy] = useState(false);
  const [msg, setMsg] = useState('');
  const attach = async () => {
    if (!folder.trim()) return;
    setBusy(true); setMsg('');
    try {
      const r = await api<{ ok: boolean; id: string; warning?: string }>('/api/projects/attach', {
        method: 'POST',
        body: JSON.stringify({ path: folder.trim() }),
      });
      setMsg(r.warning ? `attached (${r.warning})` : 'attached ✓');
      await onAttached(r.id);
    } catch (e) {
      setMsg(e instanceof Error ? e.message : String(e));
    } finally { setBusy(false); }
  };
  return (
    <details className="extguide">
      <summary>📁 Attach project folder</summary>
      <p className="extnote">
        Open a previously synced project (<b>full</b> sync — carries the substrate + git history).
        RHA re-attaches to its ledger; new prompts and edits continue the same provenance chain.{' '}
        <a className="wikilink" href="/wiki/deploy-sync.html" target="_blank" rel="noreferrer">wiki</a>
      </p>
      <input
        type="text"
        value={folder}
        placeholder="/absolute/path/to/project"
        onChange={e => setFolder(e.target.value)}
        onKeyDown={e => { if (e.key === 'Enter') void attach(); }}
      />
      <button type="button" className="ghostbtn" disabled={busy || !folder.trim()} onClick={() => void attach()}>
        {busy ? 'Attaching…' : 'Attach'}
      </button>
      {msg && <p className="extnote">{msg}</p>}
    </details>
  );
}

function Empty() {
  return (
    <div className="empty">
      <h2>No job selected</h2>
      <ol>
        <li><b>1 · IR contract</b> — the prompt is feature-coded and each node receives a frozen test suite before any code exists. You approve the contract.</li>
        <li><b>2 · Build &amp; authenticate</b> — each feature is generated against its frozen suite and verified in real time. You approve the build.</li>
        <li><b>3 · Applying RHA</b> — algorithmic and idiosyncratic bugs are localized to tree nodes and repaired inside their boundaries. Fully automatic.</li>
      </ol>
    </div>
  );
}

function phaseLabel(p: string): string {
  return ({
    created: 'starting', encoding: 'encoding features', 'generating-ir': 'generating IR',
    'awaiting-ir-approval': 'IR ready — review', building: 'building',
    'awaiting-build-approval': 'build ready — review', healing: 'applying RHA',
    complete: 'complete', failed: 'failed', interrupted: 'interrupted — resume',
    'insufficient-input': 'input insufficient — revise',
  } as Record<string, string>)[p] ?? p;
}

/** SET 23a — parked gates must be unmissable (not quiet phase text). */
function gateChip(phase: string): string | null {
  if (phase === 'awaiting-ir-approval') return '⏸ needs approval — IR contract';
  if (phase === 'awaiting-build-approval') return '⏸ needs approval — build';
  if (phase === 'insufficient-input') return '⏸ needs input — revise prompt';
  return null;
}

function JobView({ job, onChange }: { job: Job; onChange: () => Promise<void> }) {
  const [events, setEvents] = useState<JobEvent[]>([]);
  const esRef = useRef<EventSource | null>(null);

  useEffect(() => {
    setEvents([]);
    const es = new EventSource(`${API_BASE}/api/jobs/${job.id}/events`);
    esRef.current = es;
    es.onmessage = m => {
      const ev = JSON.parse(m.data) as JobEvent;
      setEvents(prev => (prev.some(p => p.seq === ev.seq) ? prev : [...prev, ev]));
      if (ev.kind === 'phase' || ev.kind === 'auth-result' || ev.kind === 'complete') void onChange();
    };
    return () => es.close();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [job.id]);

  const step = PHASE_STEP[job.phase] ?? 1;
  const running = !TERMINAL.has(job.phase);
  const lastEventTs = events.length ? events[events.length - 1].ts : undefined;
  const elapsed = useElapsed(job.createdAt, running, lastEventTs);
  const { overall, byFeature, eta } = computeProgress(job, events);
  const approve = async (gate: 'ir' | 'build') => {
    await api(`/api/jobs/${job.id}/approve`, { method: 'POST', body: JSON.stringify({ gate }) });
    await onChange();
  };
  const resume = async () => {
    await api(`/api/jobs/${job.id}/resume`, { method: 'POST', body: '{}' });
    await onChange();
  };

  return (
    <div>
      <header className="jobhead">
        <h2>{job.title || 'Untitled artifact'}</h2>
        <code className="jid">{job.id}</code>
        <span className="elapsed">{fmtTime(job.createdAt)} · {elapsed}</span>
        {job.autopilot && <span className="autobadge" title="Unsupervised E2E — gates auto-approved, decisions recorded in the ledger">⚡ unsupervised</span>}
        <TokenMeter calls={job.llmUsage ?? []} />
      </header>
      <Stepper step={step} phase={job.phase} />
      <OverallBar pct={overall} eta={eta} phase={job.phase} />
      {job.error && <div className="error">✕ {job.error}</div>}
      {job.phase === 'interrupted' && (
        <div className="card gap">
          <p className="stagenote">
            This run was interrupted mid-phase. The workspace and ledger are intact — resume continues
            from the last completed stage without redoing healed nodes.
          </p>
          <button className="primary big" onClick={() => void resume()}>Resume run</button>
        </div>
      )}

      {job.phase === 'insufficient-input' && job.promptAssessment && (
        <InputGateStage assessment={job.promptAssessment} prompt={job.prompt} jobId={job.id} onChange={onChange} />
      )}
      {job.irEscalations?.length > 0 && <IrEscalations items={job.irEscalations} />}
      {job.phase !== 'insufficient-input' && job.phase !== 'interrupted' && step === 1 && <IrStage job={job} events={events} byFeature={byFeature} onApprove={() => void approve('ir')} />}
      {job.phase !== 'interrupted' && step === 2 && <BuildStage job={job} events={events} byFeature={byFeature} onApprove={() => void approve('build')} />}
      {job.phase !== 'interrupted' && step === 3 && <HealingStage events={events} />}
      {step === 4 && job.report && <ReportStage job={job} />}
      {(job.phase === 'complete' || job.phase === 'interrupted') && job.spec && job.manifest && (
        job.pinned || job.phase === 'complete'
          ? <ProjectWorkspace job={job} onChange={onChange} />
          : <RecomputePanel job={job} onChange={onChange} />
      )}
      {job.scaffoldWarning && (
        <div className="error" style={{ marginTop: 12 }}>
          ✕ {job.scaffoldWarning}
        </div>
      )}

      <EventLog events={events} startTs={job.createdAt} defaultOpen={TERMINAL.has(job.phase)} />
    </div>
  );
}

function Stepper({ step, phase }: { step: number; phase: string }) {
  const items = ['IR contract', 'Build & authenticate', 'Applying RHA', 'Complete'];
  return (
    <div className="stepper">
      {items.map((label, i) => {
        const n = i + 1;
        const cls = n < step ? 'done' : n === step ? (phase === 'failed' ? 'fail' : 'now') : 'todo';
        return (
          <div key={label} className={'step ' + cls}>
            <span className="dot">{n < step ? '✓' : n}</span> {label}
          </div>
        );
      })}
    </div>
  );
}

// --- IR-level escalations (partial success) ----------------------------------

// SET 57f — was a duplicate `Escalation` declaration (the known pre-existing
// web typecheck errors); the IR-gate shape gets its own name so App.tsx is
// importable by tests (vite hard-fails duplicate declarations).
type IrEscalation = {
  featureId: string;
  featureName: string;
  diagnostics: string[];
  gate?: 'spec' | 'stub' | 'ir';
  collateralOf?: string;
};

const GATE_COPY: Record<'spec' | 'stub' | 'ir', { title: (n: number) => string; note: string }> = {
  spec: {
    title: n => `⚠ ${n} feature(s) escalated at the SPEC gate`,
    note: 'The feature definition itself could not validate (contract/structural). These never reached test-suite generation — their diagnostics are spec-level, not suite failures.',
  },
  stub: {
    title: n => `⚠ ${n} feature(s) escalated at the STUB gate`,
    note: 'The feature’s interface contract could not compile or could not grow to satisfy its callers. Dependent suites were blocked by the contract, not by their own defects.',
  },
  ir: {
    title: n => `⚠ ${n} feature(s) escalated at the IR gate`,
    note: 'These test suites could not conform to the frozen interface stubs within the bounded budget (including fresh decorrelated draws). Suites are independent, so each was excluded alone — the rest froze and built.',
  },
};

function IrEscalations({ items }: { items: IrEscalation[] }) {
  const collateral = items.filter(e => e.collateralOf);
  const byGate = (g: 'spec' | 'stub' | 'ir') =>
    items.filter(e => (e.gate ?? 'ir') === g && !e.collateralOf);
  return (
    <div className="iresc">
      {(['spec', 'stub', 'ir'] as const).map(g => {
        const group = byGate(g);
        if (!group.length) return null;
        return (
          <div key={g}>
            <h3 className="sechead">{GATE_COPY[g].title(group.length)} — the rest of the run proceeded</h3>
            <p className="stagenote">{GATE_COPY[g].note} Each is recorded in the ledger.</p>
            {group.map(e => (
              <div className="card gap" key={e.featureId}>
                <div className="cardhead"><b>{e.featureId}</b> {e.featureName}</div>
                <details>
                  <summary>{e.diagnostics.length} finding(s)</summary>
                  <ul>{e.diagnostics.slice(0, 12).map((d, i) => <li key={i}><code>{d}</code></li>)}</ul>
                </details>
              </div>
            ))}
          </div>
        );
      })}
      {collateral.length > 0 && (
        <div>
          <h3 className="sechead">◦ {collateral.length} feature(s) excluded as collateral — no defect of their own</h3>
          <p className="stagenote">
            These depended on an escalated feature and could not build without it. They were not
            attempted and are not failures; restoring the feature they depend on restores them.
          </p>
          {collateral.map(e => (
            <div className="card gap" key={e.featureId}>
              <div className="cardhead">
                <b>{e.featureId}</b> {e.featureName}
                <span className="dim"> — dependent of {e.collateralOf}</span>
              </div>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

// --- Phase 0 outcome: input gate ----------------------------------------------

function buildResolverQuery(prompt: string, assessment: PromptAssessment): string {
  const decisions = assessment.deficiencies
    .map((d, i) => `${i + 1}. ${d.aspect}\n   ${d.question}`)
    .join('\n');
  return [
    `I'm feeding a specification into a test-first code generator. It compiles a spec into frozen tests, then implementations. It needs every feature to have OBSERVABLE, TESTABLE behavior (inputs, outputs, state changes, edge cases) — but it does NOT need implementation internals; it picks algorithms and data structures itself. It paused because a few features left the *intent* ambiguous, listed below.`,
    ``,
    `Please make concrete, sensible product decisions for every open question — do NOT ask me anything back, decide for me — and return ONE complete, self-contained specification I can paste straight back in. For each feature, state what it DOES in observable terms and the key edge cases. Do not prescribe specific algorithms; describe behavior a test could assert.`,
    ``,
    `=== MY ORIGINAL SPEC ===`,
    prompt.trim(),
    ``,
    `=== FEATURES ALREADY CLEAR (keep these) ===`,
    assessment.featuresIdentified.map(f => `- ${f}`).join('\n') || '(none listed)',
    ``,
    `=== OPEN DECISIONS TO RESOLVE (decide each, then fold into the spec) ===`,
    decisions || '(none)',
    ``,
    `=== RETURN ===`,
    `The full revised specification only — a numbered list of features, each "Feature N: <name> — <observable behavior + concrete decisions + edge cases>". No preamble, no questions.`,
  ].join('\n');
}

function InputGateStage({ assessment, prompt, jobId, onChange }: {
  assessment: PromptAssessment; prompt: string; jobId: string; onChange: () => Promise<void>;
}) {
  const [copied, setCopied] = useState(false);
  const [busy, setBusy] = useState<'reassess' | 'approve' | null>(null);
  const [selected, setSelected] = useState<Record<string, boolean>>(() =>
    Object.fromEntries(assessment.deficiencies.map(d => [d.aspect, true])),
  );
  const query = buildResolverQuery(prompt, assessment);
  const copy = () => {
    void navigator.clipboard.writeText(query).then(() => {
      setCopied(true);
      setTimeout(() => setCopied(false), 1800);
    });
  };
  const selectedNames = assessment.deficiencies.map(d => d.aspect).filter(a => selected[a]);
  const reassess = async () => {
    setBusy('reassess');
    try { await api(`/api/jobs/${jobId}/reassess`, { method: 'POST' }); await onChange(); }
    catch (e) { alert(e instanceof Error ? e.message : String(e)); } finally { setBusy(null); }
  };
  const approveSelected = async (all: boolean) => {
    const names = all ? assessment.deficiencies.map(d => d.aspect) : selectedNames;
    if (!names.length) { alert('Select at least one feature to accept.'); return; }
    if (!confirm(
      `Proceed on best-effort intent for:\n\n${names.map(n => `• ${n}`).join('\n')}\n\n`
      + `No re-probe — these ${names.length} feature(s) are marked clear and the run continues `
      + (all || names.length === assessment.deficiencies.length
        ? 'to encode → IR → authenticate → RHA.'
        : `(${assessment.deficiencies.length - names.length} still open).`)
      + `\nSpot-check recommended — intent cannot be recovered downstream.`,
    )) return;
    setBusy('approve');
    try {
      await api(`/api/jobs/${jobId}/gate/approve`, {
        method: 'POST',
        body: JSON.stringify(all ? { all: true } : { features: names }),
      });
      await onChange();
    } catch (e) { alert(e instanceof Error ? e.message : String(e)); }
    finally { setBusy(null); }
  };
  return (
    <section>
      <div className="verdict gate">
        ⚠ Input insufficient — this is an analysis, not a failure
      </div>
      <p className="stagenote">
        RHA compiles feature-demarcated specifications; it does not synthesize natural language into
        procedures. The gate found intent it would have to guess. Accepting a feature proceeds on
        <b> best-effort intent</b> (spot-check recommended) — that cannot be recovered downstream.
        No tokens were spent building on a guess yet.
      </p>
      {assessment.featuresIdentified.length > 0 && (
        <>
          <h3 className="sechead">Features already demarcated clearly</h3>
          <ul className="gatelist ok-list">
            {assessment.featuresIdentified.map((f, i) => <li key={i}>✓ {f}</li>)}
          </ul>
        </>
      )}
      <h3 className="sechead">Open features — accept / auto-requalify</h3>
      <p className="stagenote small">
        Checking a box assigns that feature id/name as accepted for this run. Approving does
        <b> not</b> re-probe the model.
      </p>
      {assessment.deficiencies.map((d, i) => {
        const verdict = assessment.verdicts?.find(v => v.name === d.aspect);
        return (
          <div className="card gap" key={i}>
            <label className="autochk" style={{ marginBottom: 8 }}>
              <input
                type="checkbox"
                checked={!!selected[d.aspect]}
                onChange={e => setSelected(s => ({ ...s, [d.aspect]: e.target.checked }))}
              />
              <b>accept / auto-requalify — {d.aspect}</b>
            </label>
            <p>{d.issue}</p>
            {verdict?.assertion?.trim()
              ? <p className="stagenote small">Derived assertion: <code>{verdict.assertion}</code></p>
              : <p className="gatequestion">? {d.question}</p>}
          </div>
        );
      })}
      <div className="gatelevers">
        <button className="primary" disabled={busy !== null || selectedNames.length === 0} onClick={() => void approveSelected(false)}>
          {busy === 'approve' ? 'Accepting…' : `Accept selected (${selectedNames.length}) — proceed on best-effort intent`}
        </button>
        <button className="ghostbtn" disabled={busy !== null} onClick={() => void approveSelected(true)}>
          Accept all open features
        </button>
        <button className="ghostbtn" disabled={busy !== null} onClick={() => void reassess()}>
          {busy === 'reassess' ? 'Looking again…' : '↻ Look again (re-probe)'}
        </button>
      </div>
      <p className="stagenote small">
        <b>Accept</b> marks features clear with zero new LLM calls, then continues (encode → IR → authenticate → RHA).
        <b> Look again</b> re-enumerates and re-probes unresolved features (token burn).
        Or clarify below and recompile.
      </p>
      <h3 className="sechead">Don&apos;t know the answers? Hand them to any LLM.</h3>
      <p className="stagenote">
        Copy the query below, paste it into Claude, ChatGPT, or whatever model you have open. It will
        resolve every open decision into concrete product choices and hand back a complete spec —
        paste that into the prompt box on the left and hit Compile.
      </p>
      <div className="queryhead">
        <button className="primary" onClick={copy}>{copied ? '✓ copied' : '⧉ Copy the query for your LLM'}</button>
      </div>
      <pre className="rewrite">{query}</pre>
    </section>
  );
}

// --- Stage 1: IR contract ----------------------------------------------------

function IrStage({ job, events, byFeature, onApprove }: { job: Job; events: JobEvent[]; byFeature: Map<string, number>; onApprove: () => void }) {
  const waiting = job.phase !== 'awaiting-ir-approval';
  const caseCount = (fid: string) => job.manifest?.cases.filter(c => c.featureId === fid) ?? [];
  return (
    <section>
      {waiting ? (
        <Working label={phaseLabel(job.phase)} events={events} />
      ) : (
        <>
          <p className="stagenote">
            The prompt was encoded into {job.spec?.features.length} feature nodes; each received a frozen test
            suite derived from the spec alone — no implementation exists yet. Approving assigns this contract
            ({job.spec?.features.map(f => `${f.id} ${f.name}`).join('; ') || 'no features'})
            to development → then authenticate → then RHA heals failures. The suites below become read-only.
          </p>
          <div className="cards">
            {job.spec?.features.map(f => {
              const cases = caseCount(f.id);
              const fp = byFeature.get(f.id) ?? 0;
              return (
                <div className="card" key={f.id}>
                  <div className="cardhead">
                    <b>{f.id}</b> {f.name}
                    {f.deps.length > 0 && <span className="deps">⇠ {f.deps.join(', ')}</span>}
                  </div>
                  <p>{f.description}</p>
                  <div className="meta">
                    <span>{cases.length} cases</span>
                    <span>{cases.filter(c => c.kind === 'edge').length} edge</span>
                    <span>{cases.filter(c => c.kind === 'integration').length} integration</span>
                    <code>{f.testFile}</code>
                  </div>
                  {cases.length > 0 && (
                    <details>
                      <summary>test cases</summary>
                      <ul>{cases.map(c => <li key={c.name}><i className={'k-' + c.kind}>{c.kind}</i> {c.name}</li>)}</ul>
                    </details>
                  )}
                  <div className="featprog"><div className="featprogfill" style={{ width: `${fp}%` }} /></div>
                </div>
              );
            })}
          </div>
          <button className="primary big" onClick={onApprove}>Approve IR contract → authenticate → RHA</button>
        </>
      )}
    </section>
  );
}

// --- Stage 2: build + real-time authentication -------------------------------

function BuildStage({ job, events, byFeature, onApprove }: { job: Job; events: JobEvent[]; byFeature: Map<string, number>; onApprove: () => void }) {
  const building = job.phase === 'building';
  const rows = useMemo(() => {
    const byId = new Map(job.buildResults.map(r => [r.featureId, r]));
    return (job.spec?.features ?? []).map(f => ({ feature: f, result: byId.get(f.id) ?? null }));
  }, [job]);
  const inFlight = [...events].reverse().find(e => e.kind === 'build-feature');
  const totals = useMemo(() => {
    let features = 0, failing = 0;
    for (const r of job.buildResults) {
      features++;
      failing += r.failed;
    }
    return { features, failing };
  }, [job.buildResults]);
  return (
    <section>
      <p className="stagenote">
        Each feature is generated against its frozen suite and authenticated immediately. Failures are
        recorded as error nodes — approving does not finalize or ship; RHA runs a bounded healing pass next.
      </p>
      <table className="authtable">
        <thead><tr><th>node</th><th>feature</th><th>progress</th><th>authentication</th></tr></thead>
        <tbody>
          {rows.map(({ feature, result }) => {
            const total = result ? result.passed + result.failed : 0;
            const testPct = total > 0 ? (result!.passed / total) * 100 : 0;
            const pipelinePct = byFeature.get(feature.id) ?? 0;
            const barPct = result ? testPct : (building && inFlight?.data.featureId === feature.id ? 15 : pipelinePct);
            const barColor = result
              ? (result.failed === 0 ? 'var(--ok)' : 'var(--bad)')
              : 'var(--accent)';
            return (
              <tr key={feature.id}>
                <td><b>{feature.id}</b></td>
                <td>{feature.name}</td>
                <td className="progcell">
                  <div className="minibar"><div style={{ width: `${barPct}%`, background: barColor }} /></div>
                  <span className="minipct">{Math.round(barPct)}%</span>
                </td>
                <td>
                  {result ? (
                    result.failed === 0 && result.passed > 0
                      ? <span className="ok">✓ {result.passed}/{result.passed} passed</span>
                      : <span className="bad">✕ {result.failed} failing · {result.passed} passing</span>
                  ) : building && inFlight?.data.featureId === feature.id
                    ? <span className="spin">generating…</span>
                    : <span className="dim">queued</span>}
                </td>
              </tr>
            );
          })}
        </tbody>
      </table>
      {job.phase === 'awaiting-build-approval' && (
        <>
          <p className="stagenote">
            <b>{totals.features} features authenticated · {totals.failing} tests failing.</b>{' '}
            Approving does not finalize — RHA runs a bounded healing pass on all failures next.
            Proceed to RHA.
          </p>
          <button className="primary big" onClick={onApprove}>Proceed to RHA healing</button>
        </>
      )}
    </section>
  );
}

// --- Stage 3: RHA (automatic; the user just sees loading) --------------------

function HealingStage({ events }: { events: JobEvent[] }) {
  const last = [...events].reverse().find(e => e.kind === 'rha-repair' || e.kind === 'rha-sweep' || e.kind === 'rha-episode');
  const sub = last?.kind === 'rha-repair'
    ? `node ${last.data.featureId} ${last.data.outcome === 'healed' ? 'healed' : 'repairing'}`
    : last?.kind === 'rha-episode'
      ? `localizing ${last.data.bugClass} on ${last.data.featureId}`
      : last?.kind === 'rha-sweep'
        ? `sweep ${last.data.episode}: ${last.data.findings} finding(s)`
        : 'sweeping';
  return (
    <section className="healing">
      <div className="spinner" />
      <h3>Applying RHA…</h3>
      <p className="dim">{sub}</p>
    </section>
  );
}

// --- Stage 4: report ----------------------------------------------------------

function ReportStage({ job }: { job: Job }) {
  const r = job.report!;
  const green = r.failed === 0;
  return (
    <section>
      <div className={'verdict ' + (green ? 'ok' : 'bad')}>
        {green
          ? <>✓ Artifact complete — {r.passed}/{r.totalTests} frozen tests passing</>
          : <>✕ {r.failed} of {r.totalTests} tests still failing (escalated)</>}
      </div>
      <div className="statgrid">
        <div><b>{r.episodes.length}</b><span>repair episodes</span></div>
        <div><b>{r.filesTouchedByRha.length}</b><span>files touched by RHA</span></div>
        <div><b>{r.ledgerRows}</b><span>ledger nodes</span></div>
        <div><b>{r.commits}</b><span>supergit commits</span></div>
      </div>
      {r.episodes.length > 0 && (
        <table className="authtable">
          <thead><tr><th>#</th><th>node</th><th>bug class</th><th>outcome</th><th>parent recheck</th></tr></thead>
          <tbody>
            {r.episodes.map(e => (
              <tr key={e.episode + e.featureId + e.attempt}>
                <td>{e.episode}</td>
                <td><b>{e.featureId}</b></td>
                <td><code>{e.bugClass}</code></td>
                <td>{e.outcome === 'healed' ? <span className="ok">healed</span> : <span className="bad">{e.outcome}</span>}</td>
                <td>{e.parentRecheck}</td>
              </tr>
            ))}
          </tbody>
        </table>
      )}
      <EscalationReport escalations={r.escalations ?? []} />
      <MetricsPanel calls={job.llmUsage ?? []} />
      <div className="downloads">
        <a className="dlcard" href={`/api/jobs/${job.id}/artifact.zip`}>
          <span className="dlicon">⤓</span>
          <span className="dltext"><b>Artifact</b><small>.zip · code, frozen tests, ledger, git history</small></span>
        </a>
        <a className="dlcard" href={`/api/jobs/${job.id}/ledger`} target="_blank" rel="noreferrer">
          <span className="dlicon">⤓</span>
          <span className="dltext"><b>Decision ledger</b><small>.csv · every supergit node</small></span>
        </a>
        <a className="dlcard" href={`/api/jobs/${job.id}/debug.jsonl`} target="_blank" rel="noreferrer">
          <span className="dlicon">⤓</span>
          <span className="dltext"><b>LLM I/O trace</b><small>.jsonl · exact prompts + raw responses</small></span>
        </a>
      </div>
      <FolderSyncPanel jobId={job.id} />
      <p className="stagenote">
        Artifact lives at <code>{job.workspaceDir}</code>. Never wholly regenerated —
        modifications were bounded to
        {' '}{r.filesTouchedByRha.length > 0 ? r.filesTouchedByRha.join(', ') : 'no files (built clean)'}.
      </p>
    </section>
  );
}

/** Sync deployable files into the Provider Vault granted folder (no zip). */
function FolderSyncPanel({ jobId }: { jobId: string }) {
  const [status, setStatus] = useState<{ hasHandle: boolean; folderName: string | null; granted: boolean; error?: string } | null>(null);
  const [busy, setBusy] = useState(false);
  const [result, setResult] = useState('');
  const [synced, setSynced] = useState(false);

  useEffect(() => {
    void getDeployFolderStatus().then(setStatus);
  }, []);

  const run = async () => {
    setBusy(true); setResult(''); setSynced(false);
    try {
      const r = await syncJobToDeployFolder(jobId);
      if (r.ok) {
        setSynced(true);
        setResult(`synced → ${r.folderName}: ${r.written} written, ${r.skipped} unchanged`);
      } else {
        setResult(r.error ?? 'sync failed');
      }
      setStatus(await getDeployFolderStatus());
    } catch (e) {
      setResult(e instanceof Error ? e.message : String(e));
    } finally { setBusy(false); }
  };

  return (
    <div className="card gap" style={{ marginTop: 12 }}>
      <h3 className="sechead">Sync to local folder</h3>
      <p className="stagenote">
        Writes deployable files into the folder granted in Provider Vault options
        (write-only, single folder). Works on localhost and rha.software — no zip download.
        {status?.folderName ? <> Current grant: <code>{status.folderName}</code>.</> : <> No folder granted yet.</>}
      </p>
      <button className="primary" disabled={busy} onClick={() => void run()}>
        {busy ? 'Syncing…' : synced ? 'Synced ✓ — sync again' : 'Sync to folder'}
      </button>
      {result && <p className="stagenote small">{result}</p>}
      {status?.error && !status.hasHandle && (
        <p className="stagenote small">Open the extension options page to choose a deploy folder.</p>
      )}
    </div>
  );
}

/** Part U2 — pinned/complete project: add features OR edit (O4). */
function ProjectWorkspace({ job, onChange }: { job: Job; onChange: () => void | Promise<void> }) {
  const [intent, setIntent] = useState<'add' | 'edit'>('edit');
  const [addPrompt, setAddPrompt] = useState('');
  const [busy, setBusy] = useState(false);
  const [msg, setMsg] = useState('');

  const add = async () => {
    if (!addPrompt.trim()) return;
    setBusy(true); setMsg('');
    try {
      await api(`/api/jobs/${job.id}/add-features`, {
        method: 'POST',
        body: JSON.stringify({ prompt: addPrompt.trim() }),
      });
      setMsg('adding features — only newcomers are frozen/built; existing suites stay byte-identical');
      setAddPrompt('');
      await onChange();
    } catch (e) {
      setMsg(e instanceof Error ? e.message : String(e));
    } finally { setBusy(false); }
  };

  return (
    <section className="card gap" style={{ marginTop: 16 }}>
      <h3 className="sechead">
        Project workspace {job.pinned ? <span className="autobadge">pinned</span> : null}
      </h3>
      <p className="stagenote">
        Iterate in place — add new features or edit an existing one (O4 subtree recompute).
        Benefit holds only while work stays inside RHA.
      </p>
      <div className="gatelevers">
        <button type="button" className={intent === 'add' ? 'primary' : 'ghostbtn'} onClick={() => setIntent('add')}>Add features</button>
        <button type="button" className={intent === 'edit' ? 'primary' : 'ghostbtn'} onClick={() => setIntent('edit')}>Edit feature</button>
      </div>
      {intent === 'add' ? (
        <>
          <textarea
            rows={4}
            value={addPrompt}
            placeholder="Describe NEW capabilities to append (existing features stay frozen)."
            onChange={e => setAddPrompt(e.target.value)}
            style={{ width: '100%', font: 'inherit' }}
          />
          <button className="primary" disabled={busy || !addPrompt.trim()} onClick={() => void add()}>
            {busy ? 'Adding…' : 'Add to project'}
          </button>
          {msg && <p className="stagenote small">{msg}</p>}
        </>
      ) : (
        <RecomputePanel job={job} onChange={onChange} />
      )}
    </section>
  );
}

function RecomputePanel({ job, onChange }: { job: Job; onChange: () => void | Promise<void> }) {
  const features = job.spec?.features ?? [];
  const [featureId, setFeatureId] = useState(features[0]?.id ?? '');
  const selected = features.find(f => f.id === featureId) ?? features[0];
  const [description, setDescription] = useState(selected?.description ?? '');
  const [busy, setBusy] = useState(false);
  const [msg, setMsg] = useState('');

  useEffect(() => {
    const f = features.find(x => x.id === featureId);
    if (f) setDescription(f.description);
  }, [featureId, job.id]); // eslint-disable-line react-hooks/exhaustive-deps

  if (!features.length) return null;

  const run = async () => {
    if (!selected) return;
    setBusy(true); setMsg('');
    try {
      const nextFeatures = features.map(f =>
        f.id === selected.id ? { ...f, description } : f,
      );
      await api(`/api/jobs/${job.id}/recompute`, {
        method: 'POST',
        body: JSON.stringify({
          featureId: selected.id,
          spec: { title: job.spec!.title, prompt: job.prompt, features: nextFeatures },
        }),
      });
      setMsg(`recompute of ${selected.id} started — suite re-freeze + rebuild; dependents healed via RHA`);
      await onChange();
    } catch (e) {
      setMsg(e instanceof Error ? e.message : String(e));
    } finally { setBusy(false); }
  };

  return (
    <section className="card gap" style={{ marginTop: 16 }}>
      <h3 className="sechead">Recompute subtree</h3>
      <p className="stagenote">
        Edit one feature&apos;s spec text, then re-freeze and rebuild only that feature.
        Dependents are re-healed by RHA (escalated if they can&apos;t conform) — never silently rebuilt.
        All other suites and code stay byte-identical.
      </p>
      <label className="ctrlfield">
        <span>feature</span>
        <select value={selected?.id} onChange={e => setFeatureId(e.target.value)}>
          {features.map(f => (
            <option key={f.id} value={f.id}>{f.id} — {f.name}</option>
          ))}
        </select>
      </label>
      <label className="ctrlfield">
        <span>description</span>
        <textarea
          rows={4}
          value={description}
          onChange={e => setDescription(e.target.value)}
          style={{ width: '100%', font: 'inherit' }}
        />
      </label>
      <button
        className="primary"
        disabled={busy || !selected || description === selected.description}
        onClick={() => void run()}
      >
        {busy ? 'Recomputing…' : `Recompute subtree (${selected?.id})`}
      </button>
      {msg && <p className="stagenote small">{msg}</p>}
    </section>
  );
}

function TokenMeter({ calls }: { calls: LlmCall[] }) {
  if (!calls.length) return null;
  const t = usageTotals(calls);
  return (
    <span className="tokenmeter" title="LLM expenditure for this job (live)">
      {t.calls} calls · {fmtTokens(t.input)} in / {fmtTokens(t.output)} out
      {t.priced > 0 && <> · ~${t.cost.toFixed(2)}</>}
    </span>
  );
}

function MetricsPanel({ calls }: { calls: LlmCall[] }) {
  if (!calls.length) return null;
  const ops: LlmCall['op'][] = ['encode', 'ir', 'build', 'repair', 'fixStub'];
  const t = usageTotals(calls);
  const roles = ['operator', 'supervisor', 'pool'] as const;
  const hasRoles = calls.some(c => c.role && c.role !== 'operator');
  return (
    <>
      <h3 className="sechead">Token expenditure</h3>
      <table className="authtable">
        <thead><tr><th>phase</th><th>calls</th><th>input</th><th>output</th><th>time</th><th>est. cost</th></tr></thead>
        <tbody>
          {ops.map(op => {
            const sub = calls.filter(c => c.op === op);
            if (!sub.length) return null;
            const s = usageTotals(sub);
            const ms = sub.reduce((n, c) => n + c.ms, 0);
            return (
              <tr key={op}>
                <td><code>{op}</code></td>
                <td>{s.calls}</td>
                <td>{fmtTokens(s.input)}</td>
                <td>{fmtTokens(s.output)}</td>
                <td>{(ms / 1000).toFixed(0)}s</td>
                <td>{s.priced ? '$' + s.cost.toFixed(2) : '—'}</td>
              </tr>
            );
          })}
          <tr className="totalrow">
            <td>total</td>
            <td>{t.calls}</td>
            <td>{fmtTokens(t.input)}</td>
            <td>{fmtTokens(t.output)}</td>
            <td>{(calls.reduce((n, c) => n + c.ms, 0) / 1000).toFixed(0)}s</td>
            <td>{t.priced ? '$' + t.cost.toFixed(2) : '—'}</td>
          </tr>
        </tbody>
      </table>
      {hasRoles && (
        <>
          <h3 className="sechead">By agent role</h3>
          <table className="authtable">
            <thead><tr><th>role</th><th>calls</th><th>input</th><th>output</th><th>share</th><th>est. cost</th></tr></thead>
            <tbody>
              {roles.map(role => {
                const sub = calls.filter(c => (c.role ?? 'operator') === role);
                if (!sub.length) return null;
                const s = usageTotals(sub);
                const share = t.input + t.output > 0
                  ? Math.round(((s.input + s.output) / (t.input + t.output)) * 100)
                  : 0;
                return (
                  <tr key={role}>
                    <td><code className={role === 'supervisor' ? 'role-sup' : 'role-op'}>{role}</code></td>
                    <td>{s.calls}</td>
                    <td>{fmtTokens(s.input)}</td>
                    <td>{fmtTokens(s.output)}</td>
                    <td>{share}%</td>
                    <td>{s.priced ? '$' + s.cost.toFixed(2) : '—'}</td>
                  </tr>
                );
              })}
            </tbody>
          </table>
        </>
      )}
    </>
  );
}

function ControlPanel({ meta, onClose, onActivated }: {
  meta: Meta | null;
  onClose: () => void;
  onActivated?: (kind: 'supervisor' | 'supervisor-off') => void;
}) {
  // Canonical defaults (mirrors the engine's defaultsFromEnv) — the knobs are
  // ALWAYS populated, even before /api/config answers or with no engine at all.
  const KNOB_DEFAULTS: Record<string, number | string> = {
    swarmWidth: 3, maxPasses: 20, maxEpisodes: 50, maxAttempts: 25,
    stallLimit: 2, gateRounds: 3, swarmTempCeiling: 1.0, effort: 'xhigh',
  };
  const [cfg, setCfg] = useState<Record<string, unknown> | null>(null);
  const [roleKnobs, setRoleKnobs] = useState<Record<string, Record<string, unknown>>>({});
  const [msg, setMsg] = useState('');
  const [busy, setBusy] = useState(false);
  const [advanced, setAdvanced] = useState(!!meta?.providers?.advancedMode);
  const [ops, setOps] = useState(Math.max(1, meta?.providers?.operators?.filter(o => o?.connected).length || 1));
  const [supOn, setSupOn] = useState(!!meta?.providers?.supervisor?.connected || !!meta?.providers?.supervisorEnabled);
  const [modes, setModes] = useState(meta?.providers?.supervisorModes ?? {
    resolveIr: true, resolveAllFailures: false, resolveEscalationsOnly: true,
  });

  useEffect(() => {
    void api<{ effective: Record<string, unknown>; roles?: Record<string, Record<string, unknown>> }>('/api/config')
      .then(r => {
        setCfg(r.effective);
        setRoleKnobs(r.roles ?? {});
      }).catch(() => {});
  }, []);

  const save = async () => {
    if (!cfg) return;
    setBusy(true); setMsg('');
    try {
      await api('/api/config', { method: 'POST', body: JSON.stringify(cfg) });
      for (const [role, knobs] of Object.entries(roleKnobs)) {
        if (knobs && Object.keys(knobs).length) {
          await api(`/api/config/roles/${role}`, { method: 'POST', body: JSON.stringify(knobs) });
        }
      }
      await api('/api/providers/advanced', { method: 'POST', body: JSON.stringify({ enabled: advanced }) });
      await api('/api/providers/modes', { method: 'POST', body: JSON.stringify(modes) });
      if (supOn && !meta?.providers?.supervisor?.connected) onActivated?.('supervisor');
      setMsg('saved — takes effect on the next job');
    } catch (e) {
      setMsg(e instanceof Error ? e.message : String(e));
    } finally { setBusy(false); }
  };

  const restore = async () => {
    setBusy(true); setMsg('');
    try {
      await api('/api/config/reset', { method: 'POST', body: '{}' });
      await api('/api/providers/reset', { method: 'POST', body: '{}' });
      setAdvanced(false); setOps(1); setSupOn(false);
      setMsg('restored to Solo defaults');
      const c = await api<{ effective: Record<string, unknown> }>('/api/config');
      setCfg(c.effective);
    } catch (e) {
      setMsg(e instanceof Error ? e.message : String(e));
    } finally { setBusy(false); }
  };

  const num = (k: string, label: string, min: number, max: number, step = 1) => (
    <label className="ctrlfield" key={k}>
      <span>{label} <a className="wikilink" href={`/wiki/${k === 'swarmTempCeiling' ? 'swarm-temp' : k.replace(/([A-Z])/g, '-$1').toLowerCase()}.html`} target="_blank" rel="noreferrer">wiki</a></span>
      <input
        type="number" min={min} max={max} step={step}
        value={Number(cfg?.[k] ?? KNOB_DEFAULTS[k])}
        onChange={e => setCfg(c => ({ ...c, [k]: Number(e.target.value) }))}
      />
    </label>
  );

  const roleDial = (role: string, title: string, color: string) => {
    const knobs = roleKnobs[role] ?? {};
    const setK = (k: string, v: string) =>
      setRoleKnobs(m => {
        const next = { ...(m[role] ?? {}) } as Record<string, unknown>;
        // 0 or blank = default: the override is REMOVED and the agent inherits.
        if (v === '' || Number(v) === 0) delete next[k];
        else next[k] = Number(v);
        return { ...m, [role]: next };
      });
    const defaultOf = (k: string) => cfg?.[k] ?? KNOB_DEFAULTS[k];
    const hasOverrides = Object.keys(knobs).length > 0;
    return (
      <div style={{ border: `1px solid ${color}`, borderRadius: 8, padding: '10px 12px', marginTop: 10 }}>
        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
          <b style={{ color }}>{title}</b>
          <button
            type="button"
            className="ghostbtn"
            style={{ fontSize: 11, padding: '2px 10px' }}
            disabled={!hasOverrides}
            title="Clear every override — this agent inherits all defaults"
            onClick={() => setRoleKnobs(m => ({ ...m, [role]: {} }))}
          >
            Defaults
          </button>
        </div>
        <div className="ctrlgrid">
          {(['swarmWidth', 'maxPasses', 'maxEpisodes', 'maxAttempts', 'stallLimit', 'gateRounds'] as const).map(k => (
            <label className="ctrlfield" key={k}>
              <span>{k}</span>
              <input
                type="number"
                value={knobs[k] != null ? Number(knobs[k]) : ''}
                placeholder={`${defaultOf(k)} — default`}
                onChange={e => setK(k, e.target.value)}
              />
            </label>
          ))}
          <label className="ctrlfield">
            <span>swarm temp</span>
            <input
              type="number" step={0.05} min={0.2} max={1}
              value={knobs.swarmTempCeiling != null ? Number(knobs.swarmTempCeiling) : ''}
              placeholder={`${defaultOf('swarmTempCeiling')} — default`}
              onChange={e => setK('swarmTempCeiling', e.target.value)}
            />
          </label>
          <label className="ctrlfield">
            <span>effort</span>
            <select
              value={String(knobs.effort ?? '')}
              onChange={e => setRoleKnobs(m => {
                const next = { ...(m[role] ?? {}) } as Record<string, unknown>;
                if (e.target.value === '') delete next.effort;
                else next.effort = e.target.value;
                return { ...m, [role]: next };
              })}
            >
              <option value="">{String(defaultOf('effort'))} — default</option>
              {['low', 'medium', 'high', 'xhigh', 'max'].map(e => <option key={e} value={e}>{e}</option>)}
            </select>
          </label>
        </div>
        <p className="stagenote small" style={{ margin: '6px 0 0' }}>blank or 0 = default (inherits the global value shown in grey)</p>
      </div>
    );
  };

  // The activate toggle lives in the HEADER so a deactivated card can still be
  // turned on (a toggle inside the collapsed body is unreachable — the bug this
  // fixes). Knobs render only inside the card boundary, only when active.
  const card = (
    title: string,
    color: string,
    wiki: string,
    body: React.ReactNode,
    active: boolean,
    onToggle?: (on: boolean) => void,
  ) => (
    <div className={'agentcard' + (active ? '' : ' dimmed')} style={{ borderColor: color }}>
      <div className="agentcard-head" style={{ color }}>
        {onToggle ? (
          <label className="autochk" style={{ color }}>
            <input type="checkbox" checked={active} onChange={e => onToggle(e.target.checked)} />
            <b>{title}</b>
          </label>
        ) : (
          <b>{title}</b>
        )}
        <a className="wikilink" href={`/wiki/${wiki}.html`} target="_blank" rel="noreferrer">wiki</a>
      </div>
      {active ? body : <p className="stagenote small">deactivated — toggle to enable</p>}
    </div>
  );

  return (
    <div className="ctrlmodal" role="dialog" aria-modal="true">
      <div className="ctrlpanel">
        <header className="ctrlhead">
          <h2>Control Panel <a className="wikilink" href="/wiki/index.html" target="_blank" rel="noreferrer">Wiki</a></h2>
          <button type="button" className="ghostbtn" onClick={onClose}>Close</button>
        </header>
        <p className="stagenote">
          Advanced Mode is opt-in. Off = Solo Operator (today&apos;s path). Same runtime-config store as the owner admin.
        </p>
        <label className="autochk">
          <input type="checkbox" checked={advanced} onChange={e => setAdvanced(e.target.checked)} />
          Advanced Mode
        </label>
        {advanced && (
          <>
            <h3 className="sechead">Agent Roster</h3>
            {card('Operator(s)', 'var(--ok)', 'operator', (
              <>
                <label className="ctrlfield">
                  <span>count (1–2)</span>
                  <select value={ops} onChange={e => setOps(Number(e.target.value))}>
                    <option value={1}>1 — Solo / Failsafe</option>
                    <option value={2}>2 — dual operators (work-stealing)</option>
                  </select>
                </label>
                <p className="stagenote small">
                  Op0: {meta?.providers?.operators?.[0]?.label || meta?.providers?.operator?.label || 'assign in Vault'}
                  {ops === 2 ? ` · Op1: ${meta?.providers?.operators?.[1]?.label || 'assign in Vault'}` : ''}
                </p>
                {roleDial('operator', 'Operator 0 — knobs', 'var(--ok)')}
                {ops === 2 && roleDial('operator1', 'Operator 1 — knobs', 'var(--ok)')}
              </>
            ), true)}
            {card('Supervisor', 'var(--frost)', 'supervisor', (
              <>
                <label className="autochk"><input type="checkbox" checked={modes.resolveIr} onChange={e => setModes(m => ({ ...m, resolveIr: e.target.checked }))} /> Resolves Test Suite IR then hands off</label>
                <label className="autochk"><input type="checkbox" checked={modes.resolveAllFailures} onChange={e => setModes(m => ({ ...m, resolveAllFailures: e.target.checked, resolveEscalationsOnly: e.target.checked || m.resolveEscalationsOnly }))} /> Resolves ALL Operator failures</label>
                <label className="autochk"><input type="checkbox" checked={modes.resolveEscalationsOnly} onChange={e => setModes(m => ({ ...m, resolveEscalationsOnly: e.target.checked }))} /> Resolves ONLY Operator escalations</label>
                {modes.resolveAllFailures && modes.resolveEscalationsOnly && (
                  <p className="stagenote small">“All failures” includes escalations — both on is fine; all-failures wins.</p>
                )}
                <p className="stagenote small">Provider: {meta?.providers?.supervisor?.label || 'assign role supervisor in Vault'}</p>
                {roleDial('supervisor', 'Supervisor — knobs', 'var(--frost)')}
              </>
            ), supOn, on => {
              setSupOn(on);
              onActivated?.(on ? 'supervisor' : 'supervisor-off');
            })}
          </>
        )}
        {/* In Advanced Mode every knob lives INSIDE its role card (per-role
            config). The global grid renders only for Solo — otherwise knobs
            appear to float beneath a deactivated card, outside its boundary. */}
        {advanced ? (
          <p className="stagenote small">Runtime knobs are per-agent in Advanced Mode — tune them inside each roster card above.</p>
        ) : (
        <>
        <h3 className="sechead">Runtime knobs (Solo) <a className="wikilink" href="/wiki/index.html" target="_blank" rel="noreferrer">wiki</a></h3>
        {cfg && (
          <div className="ctrlgrid">
            {num('swarmWidth', 'swarm width (1–8)', 1, 8)}
            {num('maxPasses', 'max passes (1–100)', 1, 100)}
            {num('maxEpisodes', 'max episodes (1–200)', 1, 200)}
            {num('maxAttempts', 'max attempts (1–100)', 1, 100)}
            {num('stallLimit', 'stall limit (1–10)', 1, 10)}
            {num('gateRounds', 'gate rounds (1–10)', 1, 10)}
            {num('swarmTempCeiling', 'swarm temp ceiling (0.2–1.0)', 0.2, 1, 0.05)}
            <label className="ctrlfield">
              <span>effort <a className="wikilink" href="/wiki/effort.html" target="_blank" rel="noreferrer">wiki</a></span>
              <select
                value={String(cfg.effort ?? 'xhigh')}
                onChange={e => setCfg(c => ({ ...c, effort: e.target.value }))}
              >
                {['low', 'medium', 'high', 'xhigh', 'max'].map(e => <option key={e} value={e}>{e}</option>)}
              </select>
            </label>
          </div>
        )}
        </>
        )}
        <h3 className="sechead">Deploy sync (V1)</h3>
        <p className="stagenote">
          On build success / recompute, the LOCAL ENGINE copies deployable files into this folder (incremental). Leave blank to disable.
          {!meta && ' Requires the local engine — web-runtime compiles export their repository from the run view instead.'}
        </p>
        {cfg && (
          <div className="ctrlgrid">
            <label className="ctrlfield" style={{ gridColumn: '1 / -1' }}>
              <span>output directory</span>
              <input
                type="text"
                value={String(cfg.outputDir ?? '')}
                placeholder="/absolute/path/to/deploy"
                onChange={e => setCfg(c => ({ ...c, outputDir: e.target.value }))}
              />
            </label>
            <label className="ctrlfield">
              <span>sync mode <a className="wikilink" href="/wiki/deploy-sync.html" target="_blank" rel="noreferrer">wiki</a></span>
              <select
                value={String(cfg.syncMode ?? 'full')}
                onChange={e => setCfg(c => ({ ...c, syncMode: e.target.value }))}
              >
                <option value="full">full — portable project (code + tests + .supergit + git history)</option>
                <option value="deployable">deployable — production push (code + tests only)</option>
              </select>
            </label>
            <p className="stagenote small" style={{ gridColumn: '1 / -1' }}>
              Full is the default: the folder carries the complete substrate, so any later RHA
              session can re-attach and keep working on it. Deployable strips the scaffolding for
              a production target — that copy is not re-attachable.
            </p>
          </div>
        )}
        <div className="gatelevers" style={{ marginTop: 16 }}>
          <button className="primary" disabled={busy || !cfg} onClick={() => void save()}>
            {busy ? 'Saving…' : 'Save'}
          </button>
          <button className="ghostbtn" disabled={busy} onClick={() => void restore()}>Restore to Defaults</button>
          <button className="ghostbtn" onClick={onClose}>Cancel</button>
        </div>
        {msg && <p className="stagenote small">{msg}</p>}
      </div>
    </div>
  );
}

function EscalationReport({ escalations }: { escalations: Escalation[] }) {
  if (!escalations.length) return null;
  return (
    <>
      <h3 className="sechead">Escalation report — surfaced for your judgment</h3>
      <p className="stagenote">
        RHA stopped without healing these nodes. Nothing was regenerated and the frozen IR was not
        modified; each entry explains why bounded repair could not proceed and what to do.
        This report is also in the artifact export as <code>.supergit/escalation.md</code>.
      </p>
      {escalations.map(e => (
        <div className="card esc" key={e.featureId}>
          <div className="cardhead">
            <b>{e.featureId}</b> {e.featureName}
            <span className="deps"><code>{e.bugClass}</code> · {e.attempts} repair attempt{e.attempts === 1 ? '' : 's'}</span>
          </div>
          {e.suspectedContractSkew && (
            <p className="skew">
              ⚠ suspected contract skew with {e.suspectedContractSkew.withFeatures.join(', ')} —
              “{e.suspectedContractSkew.evidence.slice(0, 160)}”
            </p>
          )}
          <p className="rec">{e.recommendation}</p>
          <details>
            <summary>{e.findings.length} finding(s)</summary>
            <ul>
              {e.findings.slice(0, 12).map((f, i) => (
                <li key={i}><i className="k-edge">{f.bugClass}</i> <code>{f.file}{f.line ? ':' + f.line : ''}</code> {f.testName ? `(${f.testName}) ` : ''}{f.message.split('\n')[0].slice(0, 140)}</li>
              ))}
            </ul>
          </details>
          {e.ledgerTrail.length > 0 && (
            <details>
              <summary>ledger trail</summary>
              <ul>{e.ledgerTrail.map((l, i) => <li key={i}><code>{l}</code></li>)}</ul>
            </details>
          )}
        </div>
      ))}
    </>
  );
}

function Working({ label, events }: { label: string; events: JobEvent[] }) {
  const last = events.length ? fmtEvent(events[events.length - 1]).label : null;
  return (
    <div className="healing">
      <div className="spinner" />
      <h3>{label}…</h3>
      {last && <p className="dim small">{last}</p>}
    </div>
  );
}
