/* global React, store, Panel, StatCell */
// Council page — multi-model reasoning with gut-feel

const { useState, useEffect, useMemo, useRef } = React;

const MODELS = [
  { id: "claude",     name: "Claude Opus 4.5",   vendor: "Anthropic",  icon: "◆", color: "var(--violet)", style: "Structured, risk-aware, loves stop-losses.",       traits: { data: 0.85, gut: 0.60, risk: 0.90 } },
  { id: "gpt",        name: "GPT-5",             vendor: "OpenAI",     icon: "✦", color: "var(--up)",     style: "Balanced; weighs consensus and precedent.",        traits: { data: 0.80, gut: 0.55, risk: 0.70 } },
  { id: "gemini",     name: "Gemini 2.5 Pro",    vendor: "Google",     icon: "◈", color: "var(--cyan)",   style: "Quant-heavy; strong on chart patterns.",           traits: { data: 0.92, gut: 0.35, risk: 0.78 } },
  { id: "perplexity", name: "Sonar Pro",         vendor: "Perplexity", icon: "▲", color: "var(--amber)",  style: "News-driven; cites live sources.",                 traits: { data: 0.70, gut: 0.45, risk: 0.60 } },
  { id: "grok",       name: "Grok 4",            vendor: "xAI",        icon: "✕", color: "var(--down)",   style: "Contrarian; high gut-feel tolerance, bold sizing.",traits: { data: 0.60, gut: 0.88, risk: 0.35 } },
  { id: "deepseek",   name: "DeepSeek R1",       vendor: "DeepSeek",   icon: "◐", color: "#8cff8c",       style: "First-principles; slow but deliberate.",           traits: { data: 0.88, gut: 0.40, risk: 0.85 } },
];

// ============================================================================
// 8-role council — each role has a distinct job description, sub-council
// assignment, and a default LLM brand to run it.  Models stay independent —
// any model can run any role; this list just configures what the desk
// LOOKS LIKE, not which brain runs each row.
// ============================================================================
const ROLES = [
  // ANALYSIS sub-council — what's the trade?
  { id: "quant",        name: "Quant Analyst",         sub_council: "analysis",  icon: "▦", color: "var(--cyan)",   short_focus: "Indicators · patterns · statistical edge", default_model: "claude" },
  { id: "fundamentals", name: "Fundamentals Analyst",  sub_council: "analysis",  icon: "◆", color: "var(--violet)", short_focus: "Earnings · P/E · business momentum",        default_model: "claude" },
  { id: "macro",        name: "Macro / Flow Analyst",  sub_council: "analysis",  icon: "✦", color: "var(--amber)",  short_focus: "FII/DII · USD/INR · global cross-asset",    default_model: "perplexity" },
  // RISK sub-council — what could break it?
  { id: "risk_officer", name: "Risk Officer",          sub_council: "risk",      icon: "▲", color: "var(--down)",   short_focus: "Sizing · stop · drawdown · correlation",     default_model: "claude" },
  { id: "vol",          name: "Volatility Strategist", sub_council: "risk",      icon: "◈", color: "#5ec5b8",       short_focus: "IV regime · gamma · hedge cost",            default_model: "claude" },
  { id: "devil",        name: "Devil's Advocate",      sub_council: "risk",      icon: "✕", color: "#ff7ad9",       short_focus: "Forced contrarian · fade consensus",        default_model: "grok" },
  // EXECUTION sub-council — how to enter?
  { id: "execution",    name: "Execution Trader",      sub_council: "execution", icon: "◐", color: "var(--up)",     short_focus: "Order type · timing · depth-of-book",       default_model: "claude" },
  { id: "compliance",   name: "Compliance Officer",    sub_council: "execution", icon: "⚷", color: "#ffc850",       short_focus: "SEBI · F&O lot · circuits · ban-list",      default_model: "deepseek" },
];

// Sub-council cycle order — clicking the badge rotates through these.
const SUB_COUNCIL_CYCLE = ["analysis", "risk", "execution"];

// Anthropic Claude Sonnet 4.5 pricing (USD per 1M tokens).  Used to
// estimate cost + savings from prompt caching.  Numbers track public
// rates as of late 2025; if Anthropic shifts pricing, edit here.
// All council Anthropic calls (native + fallback path) go through
// Sonnet, so a single rate card is enough today.
const PRICING = {
  input:        3.00,   // fresh input
  output:       15.00,  // generated output
  cache_read:   0.30,   // 10% of input — the savings lever
  cache_write:  3.75,   // 1.25× input — pays once, reads cheap forever (5-min TTL)
};
const _PER_M = 1_000_000;
function computeCostBreakdown(u) {
  // u = { input, output, cache_read, cache_write } in token counts.
  const c = {
    write_cost: (u.cache_write || 0) / _PER_M * PRICING.cache_write,
    read_cost:  (u.cache_read  || 0) / _PER_M * PRICING.cache_read,
    fresh_in_cost: (u.input || 0)    / _PER_M * PRICING.input,
    out_cost:   (u.output || 0)      / _PER_M * PRICING.output,
  };
  c.total = c.write_cost + c.read_cost + c.fresh_in_cost + c.out_cost;
  // What we'd have paid without caching — every cached token billed at full input rate.
  const billed_no_cache_input = (u.input || 0) + (u.cache_read || 0) + (u.cache_write || 0);
  c.uncached_total = billed_no_cache_input / _PER_M * PRICING.input + c.out_cost;
  c.saved = Math.max(0, c.uncached_total - c.total);
  const reads = u.cache_read || 0;
  const fresh_or_write = (u.input || 0) + (u.cache_write || 0);
  c.cache_hit_pct = (reads + fresh_or_write) > 0
    ? (reads / (reads + fresh_or_write)) * 100
    : 0;
  return c;
}

const PROMPT_MODES = [
  { id: "pure_data",      label: "Pure Data",      desc: "Only price/indicator/news; no gut",           weights: { data: 1.0, gut: 0.0 } },
  { id: "balanced",       label: "Balanced",       desc: "70% data, 30% intuition",                     weights: { data: 0.7, gut: 0.3 } },
  { id: "gut_heavy",      label: "Gut-Heavy",      desc: "40% data, 60% intuition — vibe check",        weights: { data: 0.4, gut: 0.6 } },
  { id: "contrarian",     label: "Contrarian",     desc: "Fade consensus; take the other side",         weights: { data: 0.5, gut: 0.5 } },
  { id: "momentum",       label: "Momentum",       desc: "Ride the trend, ignore reversion",            weights: { data: 0.6, gut: 0.4 } },
];

// Council variant defaults.  Each council is the same engine — the differences
// here are *starting points*: which roles are pre-selected, what mode opens by
// default, what symbol is pre-loaded.  Every field stays editable in the UI;
// the only thing the backend enforces is the horizon constraint per type.
const COUNCIL_TYPES = {
  swing: {
    label: "SWING COUNCIL",
    blurb: "1-7 day holds. ATR stops, standard sizing.",
    icon: "✦", color: "var(--violet)",
    default_roles: ["quant", "fundamentals", "macro", "risk_officer", "vol", "devil", "execution", "compliance"],
    default_mode: "balanced",
    default_symbol: "RELIANCE",
  },
  trade: {
    label: "TRADE COUNCIL",
    blurb: "High velocity. Position flat by close (intraday/scalp).",
    icon: "◐", color: "var(--up)",
    default_roles: ["quant", "execution", "risk_officer", "vol"],
    default_mode: "momentum",
    default_symbol: "NIFTY",
    default_velocity: "intraday",
  },
  investment: {
    label: "INVESTMENT COUNCIL",
    blurb: "Positional. Weeks-to-quarters; wider stops.",
    icon: "◈", color: "var(--cyan)",
    default_roles: ["fundamentals", "macro", "quant", "risk_officer", "compliance"],
    default_mode: "balanced",
    default_symbol: "RELIANCE",
  },
};

const VELOCITY_OPTIONS = [
  { id: "scalp",       label: "Scalp",       desc: "Seconds-to-minutes. Tightest stops; flat by close." },
  { id: "short_swing", label: "Short-swing", desc: "Hours-to-1-day. Overnight allowed if conviction high." },
  { id: "intraday",    label: "Intraday",    desc: "Minutes-to-hours. Flat by market close." },
];

function _resolveCouncilCfg(councilType) {
  return COUNCIL_TYPES[councilType] || COUNCIL_TYPES.swing;
}

function CouncilPage({ app, quotes, initialView, councilType, velocity: initialVelocity }) {
  const cfg = _resolveCouncilCfg(councilType);
  const [symbol, setSymbol] = useState(() => cfg.default_symbol || "RELIANCE");
  const [mode, setMode] = useState(() => PROMPT_MODES.find(m => m.id === cfg.default_mode) || PROMPT_MODES[1]);
  const [velocity, setVelocity] = useState(() => initialVelocity || cfg.default_velocity || null);
  // `enabled` is now a Set of ROLE ids (not model ids).
  const [enabled, setEnabled] = useState(() => new Set(cfg.default_roles));
  // role_id → model_id mapping.  Default from ROLES[].default_model.
  const [roleModels, setRoleModels] = useState(() => {
    const m = {}; ROLES.forEach(r => { m[r.id] = r.default_model; }); return m;
  });
  // role_id → sub_council override (defaults to ROLES[].sub_council).
  const [roleSubCouncils, setRoleSubCouncils] = useState(() => {
    const m = {}; ROLES.forEach(r => { m[r.id] = r.sub_council; }); return m;
  });
  const [modelPickerFor, setModelPickerFor] = useState(null);
  const [running, setRunning] = useState(false);
  // Empty initial state — page reflects reality on first paint.  No demo
  // seed.  Verdicts / chair / progress populate from real SSE events when
  // the user clicks CONVENE COUNCIL.
  const [verdicts, setVerdicts] = useState(null);
  const [chair, setChair] = useState(null);
  const [chairStatus, setChairStatus] = useState("idle");
  const [progress, setProgress] = useState({});
  const [plan, setPlan] = useState(null);
  const [expandedModel, setExpandedModel] = useState(null);
  const [autoMode, setAutoMode] = useState(false);
  // Token-usage accumulator — driven by `usage` payloads on chain_step
  // events.  Used by CostPanel to render live cost + cache hit %.
  const [usageTotals, setUsageTotals] = useState({
    input: 0, output: 0, cache_read: 0, cache_write: 0, calls: 0,
  });
  // Live backend visibility — populated by the new gather_*/vendor_* events
  // emitted by ui_backend._gather_council_inputs and council_runner._call_*.
  // gatherStage   — last gather_stage event (cache layer currently being hit)
  // vendorActivity — { [role_id|"chair"]: { vendor, model, status,
  //                    wait_secs?, retry_until_ts?, attempt?, latency_ms? } }
  // activityLog   — rolling buffer of every backend event for the
  //                 BACKEND ACTIVITY panel (newest-first, capped 200)
  const [gatherStage,    setGatherStage]    = useState(null);
  const [vendorActivity, setVendorActivity] = useState({});
  const [activityLog,    setActivityLog]    = useState([]);
  // View is now driven by the sidebar (council vs council_history page keys
  // — see app.jsx).  We mirror it locally so click-replay from History can
  // hop back to Live without re-routing through the sidebar.
  const [view, setView] = useState(initialView || "live");
  useEffect(() => {
    if (initialView && initialView !== view) setView(initialView);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [initialView]);

  const q = quotes[symbol];

  // -------------------------------------------------------------------------
  // Mount-time reattach: if a previous Convene click left a run in flight
  // (page refresh / tab close), re-open its event stream and replay.  The
  // `handleStreamEvent` reducer rebuilds plan/progress/verdicts/chair from
  // the events, so the UI catches up automatically.
  // -------------------------------------------------------------------------
  useEffect(() => {
    let cancelled = false;
    (async () => {
      let runId;
      try { runId = localStorage.getItem("council:active_run_id"); } catch (_) {}
      if (!runId) return;
      try {
        const r = await fetch(`/api/council/runs/${encodeURIComponent(runId)}`);
        if (!r.ok) {
          try { localStorage.removeItem("council:active_run_id"); } catch (_) {}
          return;
        }
        const j = await r.json();
        const run = j.run || {};
        if (!["queued", "running"].includes(run.status)) {
          try { localStorage.removeItem("council:active_run_id"); } catch (_) {}
          return;
        }
        if (cancelled) return;
        // Restore the symbol/mode that was actually being run, so the
        // header + verdict cards aren't lying about what's on screen.
        if (run.symbol) setSymbol(run.symbol);
        if (run.mode) {
          const m = PROMPT_MODES.find(pm => pm.id === run.mode);
          if (m) setMode(m);
        }
        setRunning(true);
        setVerdicts(prev => (prev || []).map(v => ({ ...v, stale: true })));
        setChair(prev => prev ? { ...prev, stale: true } : prev);
        setChairStatus("idle");
        setProgress({});
        setPlan(null);
        setGatherStage(null);
        setVendorActivity({});
        setActivityLog([]);
        await attachToRun(runId, -1);
      } catch (e) {
        console.error("council reattach failed", e);
      } finally {
        if (!cancelled) setRunning(false);
      }
    })();
    return () => { cancelled = true; };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  const runCouncil = async () => {
    setRunning(true);
    // KEEP existing verdicts + chair visible during the run.  We mark them
    // as `stale=true` so the UI can show a subtle "refreshing" hint, and we
    // replace each one in place when the real result for that role arrives.
    // This avoids the blank-page flash between Convene click and first
    // streaming event (~5-15s for the analyse step on large prompts).
    setVerdicts(prev => (prev || []).map(v => ({ ...v, stale: true })));
    setProgress({});                       // progress restarts fresh
    setChair(prev => prev ? { ...prev, stale: true } : prev);
    setChairStatus("idle");
    setPlan(null);
    setExpandedModel(null);
    setUsageTotals({ input: 0, output: 0, cache_read: 0, cache_write: 0, calls: 0 });
    setGatherStage(null);
    setVendorActivity({});
    setActivityLog([]);

    // Build role payload — one entry per enabled role with its assigned
    // model + sub-council.  Backend now treats each role as an independent
    // chain, so 8 roles → 8 chains (no dedupe).
    const enabledRoleIds = ROLES.filter(r => enabled.has(r.id)).map(r => r.id);

    // Check that the assigned models are callable (own key OR Anthropic fallback).
    let callableIds = new Set();
    try {
      const r = await fetch("/api/council/models");
      if (r.ok) {
        const j = await r.json();
        (j.models || []).forEach(m => { if (m.callable) callableIds.add(m.model_id); });
      }
    } catch (_) {}

    // Pre-seed progress state IMMEDIATELY (one row per ROLE) so the UI
    // animates from queued the moment Convene is clicked.
    const seeded = {};
    enabledRoleIds.forEach(rid => {
      const role = ROLES.find(r => r.id === rid);
      seeded[rid] = {
        role_id: rid, role_name: role?.name, role_icon: role?.icon, role_color: role?.color,
        model_id: roleModels[rid] || role?.default_model,
        sub_council: roleSubCouncils[rid] || role?.sub_council,
        status: "queued",
        steps: { analyse: null, critique: null, decide: null },
      };
    });
    setProgress(seeded);
    setPlan({
      personas: enabledRoleIds.map(rid => {
        const role = ROLES.find(r => r.id === rid);
        return {
          role_id: rid, role_name: role?.name,
          model_id: roleModels[rid] || role?.default_model,
          sub_council: roleSubCouncils[rid] || role?.sub_council,
        };
      }),
    });

    // Run the real role-based stream.  Backend will fall back to Sonnet for
    // any role whose assigned model has no key — handled server-side.
    if (enabledRoleIds.length > 0) {
      try {
        await streamCouncilRun(enabledRoleIds);
      } catch (e) {
        console.error("council stream failed", e);
      }
    }

    setRunning(false);
  };

  // Open SSE stream + dispatch progress events into local state.
  // Two phases now:
  //   1. POST /api/council/runs → get a run_id (background task starts)
  //   2. Open GET /api/council/runs/{run_id}/stream and read SSE
  // The run_id is saved to localStorage so a page refresh can reattach
  // mid-flight via GET /api/council/runs/active on mount.
  const ACTIVE_RUN_KEY = "council:active_run_id";
  const streamCouncilRun = async (roleIds) => {
    const role_models = {};
    const role_sub_councils = {};
    roleIds.forEach(rid => {
      const role = ROLES.find(r => r.id === rid);
      role_models[rid] = roleModels[rid] || role?.default_model;
      role_sub_councils[rid] = roleSubCouncils[rid] || role?.sub_council;
    });
    const createRes = await fetch("/api/council/runs", {
      method: "POST", headers: { "content-type": "application/json" },
      body: JSON.stringify({
        symbol, mode: mode.id,
        roles: roleIds, role_models, role_sub_councils,
        council_type: councilType || "swing",
        velocity: velocity || null,
      }),
    });
    if (!createRes.ok) throw new Error("create run failed: HTTP " + createRes.status);
    const { run_id } = await createRes.json();
    try { localStorage.setItem(ACTIVE_RUN_KEY, run_id); } catch (_) {}
    await attachToRun(run_id, -1);
  };

  // Connect to a run's event stream — used by streamCouncilRun on a fresh
  // Convene AND by the mount-time reattach path after a page refresh.
  // `lastSeq` lets a reconnect resume after a network blip.
  const attachToRun = async (run_id, lastSeq) => {
    const url = `/api/council/runs/${encodeURIComponent(run_id)}/stream?last_seq=${lastSeq}`;
    const res = await fetch(url, { headers: { "accept": "text/event-stream" } });
    if (!res.ok || !res.body) throw new Error("HTTP " + res.status);

    const reader = res.body.getReader();
    const decoder = new TextDecoder();
    let buffer = "";
    let terminated = false;

    while (!terminated) {
      const { value, done } = await reader.read();
      if (done) break;
      buffer += decoder.decode(value, { stream: true });
      let idx;
      while ((idx = buffer.indexOf("\n\n")) >= 0) {
        const frame = buffer.slice(0, idx);
        buffer = buffer.slice(idx + 2);
        const lines = frame.split("\n");
        let evType = "message", evData = "";
        for (const line of lines) {
          if (line.startsWith("event:")) evType = line.slice(6).trim();
          else if (line.startsWith("data:"))  evData += line.slice(5).trim();
        }
        if (!evData) continue;
        let payload;
        try { payload = JSON.parse(evData); } catch (_) { continue; }
        if (evType === "hello") continue; // backend ack; nothing to dispatch
        handleStreamEvent(evType, payload);
        if (evType === "complete" || evType === "error") {
          terminated = true;
          try { localStorage.removeItem(ACTIVE_RUN_KEY); } catch (_) {}
        }
      }
    }
  };

  // Each event now carries `role_id` (preferred row identity) plus
  // `model_id` (the brain).  When role_id isn't present (legacy persona
  // path) we fall back to model_id keying.
  const _key = (p) => p.role_id || p.model_id;

  const handleStreamEvent = (type, payload) => {
    if (type === "plan") {
      setPlan(payload);
      setProgress(prev => {
        const next = { ...prev };
        (payload.personas || []).forEach(p => {
          const k = _key(p);
          if (!k) return;
          const role = p.role_id ? ROLES.find(r => r.id === p.role_id) : null;
          next[k] = next[k] || {
            role_id: p.role_id, role_name: p.role_name || role?.name,
            role_icon: role?.icon, role_color: role?.color,
            model_id: p.model_id, sub_council: p.sub_council,
            status: "queued", steps: { analyse: null, critique: null, decide: null },
          };
        });
        return next;
      });
    } else if (type === "chain_start") {
      const k = _key(payload);
      setProgress(prev => ({
        ...prev,
        [k]: {
          ...(prev[k] || { steps: { analyse: null, critique: null, decide: null } }),
          role_id: payload.role_id, role_name: payload.role_name,
          model_id: payload.model_id,
          sub_council: payload.sub_council, status: "running", current_step: "analyse",
        },
      }));
    } else if (type === "chain_step") {
      const k = _key(payload);
      setProgress(prev => {
        const cur = prev[k] || { steps: { analyse: null, critique: null, decide: null } };
        const steps = { ...cur.steps, [payload.step]: { latency_ms: payload.latency_ms, preview: payload.preview } };
        const next_step = payload.step === "analyse" ? "critique"
                       : payload.step === "critique" ? "decide" : null;
        return {
          ...prev,
          [k]: {
            ...cur, role_id: payload.role_id, model_id: payload.model_id,
            sub_council: payload.sub_council,
            status: next_step ? "running" : "decided",
            current_step: next_step, steps,
          },
        };
      });
      // Anthropic-only: accumulate token usage for the cost panel.  Other
      // vendors don't carry a `usage` block (yet) so they won't bump the
      // counters — surfaced as "anthropic-only" in the panel footer.
      const u = payload.usage || {};
      if (u.input_tokens != null || u.output_tokens != null
          || u.cache_read_input_tokens != null || u.cache_creation_input_tokens != null) {
        setUsageTotals(prev => ({
          input:       prev.input       + (u.input_tokens || 0),
          output:      prev.output      + (u.output_tokens || 0),
          cache_read:  prev.cache_read  + (u.cache_read_input_tokens || 0),
          cache_write: prev.cache_write + (u.cache_creation_input_tokens || 0),
          calls:       prev.calls       + 1,
        }));
      }
    } else if (type === "chain_done") {
      // REPLACE the existing verdict for this role (not append) so the
      // demo placeholder is swapped out cleanly, and a stale verdict for
      // the same role from a prior run gets refreshed in place.
      const role  = payload.role_id ? ROLES.find(r => r.id === payload.role_id) : null;
      const model = MODELS.find(m => m.id === payload.model_id) || (role ? MODELS.find(m => m.id === role.default_model) : null);
      const displayModel = role
        ? { ...model, name: role.name, icon: role.icon, color: role.color, vendor: model?.vendor || "—", style: role.short_focus, role_id: role.id }
        : model;
      if (displayModel) {
        const shaped = payload.ok
          ? _shapeRealVerdict(displayModel, mode, q, { verdict: payload.verdict, used_vendor: payload.used_vendor, sub_council: payload.sub_council, chain: [] })
          : _shapeErrorVerdict(displayModel, payload.error || "no response");
        shaped.subCouncil = payload.sub_council;
        shaped.roleId = payload.role_id;
        shaped.stale = false;
        setVerdicts(prev => {
          const arr = prev || [];
          const idx = arr.findIndex(v =>
            (payload.role_id && v.roleId === payload.role_id) ||
            (!payload.role_id && v.model && v.model.id === payload.model_id));
          if (idx >= 0) {
            const next = [...arr];
            next[idx] = shaped;
            return next;
          }
          return [...arr, shaped];
        });
      }
      const k = _key(payload);
      setProgress(prev => ({
        ...prev,
        [k]: { ...(prev[k] || {}), status: payload.ok ? "done" : "error", error: payload.error },
      }));
    } else if (type === "chair_start") {
      setChairStatus("running");
    } else if (type === "chair_done") {
      // Swap the chair card in place — keep prior chair visible while
      // running, replace only when the new one arrives.
      const c = payload.chair || {};
      setChair({ ...c, stale: false });
      setChairStatus(c.ok ? "done" : "error");
    } else if (type === "complete") {
      // Attach full chain transcripts (the streaming events only had previews)
      const result = payload.result || {};
      const allChains = [];
      Object.values(result.sub_councils || {}).forEach(arr => arr.forEach(r => allChains.push(r)));
      setVerdicts(prev => {
        if (!prev) return prev;
        return prev.map(v => {
          const match = allChains.find(c => (c.role_id && c.role_id === v.roleId) || c.model_id === v.model.id);
          if (!match) return v;
          return {
            ...v,
            chain: match.chain || [],
            systemPrompt: match.system || "",
            usedVendor: match.used_vendor || v.usedVendor,
          };
        });
      });
    } else if (type === "error") {
      setChairStatus("error");
    } else if (type === "gather_start") {
      setGatherStage({ stage: "start", symbol: payload.symbol, segment: payload.segment, ts: payload.ts });
    } else if (type === "gather_stage") {
      setGatherStage({
        stage:       payload.stage,
        symbol:      payload.symbol,
        source:      payload.source,
        hit:         payload.hit,
        rows:        payload.rows,
        elapsed_ms:  payload.elapsed_ms,
        warning:     payload.warning,
        error:       payload.error,
        ts:          payload.ts,
      });
    } else if (type === "gather_done") {
      setGatherStage({
        stage: "done",
        symbol: payload.symbol,
        source: payload.source,
        indicator_count: payload.indicator_count,
        ltp: payload.ltp,
        total_elapsed_ms: payload.total_elapsed_ms,
        ts: payload.ts,
      });
    } else if (type === "vendor_call_start") {
      const k = payload.role_id || payload.role || "chair";
      setVendorActivity(prev => ({
        ...prev,
        [k]: { ...(prev[k] || {}), status: "calling", vendor: payload.vendor,
               model: payload.model, step: payload.step,
               via_fallback: payload.via_fallback, ts_start: payload.ts,
               wait_secs: null, retry_until_ts: null, attempt: null },
      }));
    } else if (type === "vendor_retry") {
      const k = payload.role_id || payload.role || "chair";
      setVendorActivity(prev => ({
        ...prev,
        [k]: { ...(prev[k] || {}), status: "rate_limited",
               vendor: payload.vendor, model: payload.model,
               step: payload.step,
               http_status: payload.status,
               attempt: payload.attempt, max_retries: payload.max_retries,
               wait_secs: payload.wait_secs,
               retry_until_ts: (payload.ts || Date.now()) + (payload.wait_secs || 0) * 1000,
               retry_after_header: payload.retry_after_header,
               body_snippet: payload.body_snippet },
      }));
    } else if (type === "vendor_call_done") {
      const k = payload.role_id || payload.role || "chair";
      setVendorActivity(prev => {
        const next = { ...prev };
        delete next[k];                      // call finished — clear the chip
        return next;
      });
    } else if (type === "vendor_call_error") {
      const k = payload.role_id || payload.role || "chair";
      setVendorActivity(prev => ({
        ...prev,
        [k]: { ...(prev[k] || {}), status: "error",
               vendor: payload.vendor, model: payload.model, step: payload.step,
               http_status: payload.status, error: payload.error,
               body_snippet: payload.body_snippet,
               wait_secs: null, retry_until_ts: null },
      }));
    }

    // BACKEND ACTIVITY log — append every event to a rolling buffer so the
    // user can see exactly what the backend is doing in real time.  The
    // existing chain_*/chair_* types are included too so this acts as a
    // single timeline.
    setActivityLog(prev => {
      const entry = { type, payload, ts: payload.ts || Date.now() };
      const next = prev.length >= 200 ? prev.slice(prev.length - 199).concat(entry) : prev.concat(entry);
      return next;
    });
  };

  // Translate the backend's structured verdict into the shape the UI expects.
  // Now also surfaces:
  //   chain[]        — analyse / critique / decide raw outputs from each step
  //   used_vendor    — actual vendor used (may be "anthropic-fallback")
  //   swing_factors  — model-supplied "what would change my mind"
  const _shapeRealVerdict = (model, mode, quote, payload) => {
    const v = payload.verdict || {};
    const finalScore = Math.max(-1, Math.min(1, Number(v.score) || 0));
    const conviction = Math.max(0, Math.min(100, Number(v.conviction) || Math.abs(finalScore) * 100));
    const action = String(v.action || "HOLD").toUpperCase();
    const chain = Array.isArray(payload.chain) ? payload.chain : [];
    const totalLatency = chain.reduce((s, c) => s + (c.latency_ms || 0), 0);
    return {
      model, action, conviction, finalScore,
      dataBias: finalScore, gutBias: 0, gutScore: 50,
      priceTarget: Number(v.price_target) || (quote ? quote.last : 0),
      stopLoss: Number(v.stop_loss) || (quote ? quote.last * 0.99 : 0),
      horizon: v.horizon || "1-3 days",
      sizeSuggest: Math.round((Math.abs(finalScore) * 1.5) * 10) / 10,
      reasoning: v.reasoning || "(no reasoning returned)",
      keyRisks: Array.isArray(v.key_risks) ? v.key_risks : [],
      swingFactors: Array.isArray(v.swing_factors) ? v.swing_factors : [],
      chain,
      usedVendor: payload.used_vendor || "",
      latencyMs: totalLatency,
      tokens: 0, costCents: 0,
      live: true,
    };
  };
  const _shapeErrorVerdict = (model, errMsg) => ({
    model, action: "—", conviction: 0, finalScore: 0,
    dataBias: 0, gutBias: 0, gutScore: 50, priceTarget: 0, stopLoss: 0,
    horizon: "—", sizeSuggest: 0,
    reasoning: `Error: ${errMsg}`, keyRisks: [], latencyMs: 0, tokens: 0, costCents: 0,
    error: true,
  });

  // synthesis
  const synthesis = useMemo(() => {
    if (!verdicts || verdicts.length === 0) return null;
    const avg = verdicts.reduce((s, v) => s + v.finalScore, 0) / verdicts.length;
    const avgGut = verdicts.reduce((s, v) => s + v.gutScore, 0) / verdicts.length;
    const avgConv = verdicts.reduce((s, v) => s + v.conviction, 0) / verdicts.length;
    const buys = verdicts.filter(v => v.action === "BUY" || v.action === "ACCUMULATE").length;
    const sells = verdicts.filter(v => v.action === "SELL" || v.action === "REDUCE").length;
    const holds = verdicts.filter(v => v.action === "HOLD").length;
    const agreement = Math.max(buys, sells, holds) / verdicts.length;
    const avgTarget = verdicts.reduce((s, v) => s + v.priceTarget, 0) / verdicts.length;
    const avgStop = verdicts.reduce((s, v) => s + v.stopLoss, 0) / verdicts.length;
    const decision = avg > 0.3 ? "BUY" : avg < -0.3 ? "SELL" : avg > 0.1 ? "ACCUMULATE" : avg < -0.1 ? "REDUCE" : "HOLD";
    return { avg, avgGut, avgConv, buys, sells, holds, agreement, avgTarget, avgStop, decision };
  }, [verdicts]);

  const toggleRole = (id) => {
    setEnabled(prev => {
      const s = new Set(prev);
      if (s.has(id)) s.delete(id); else s.add(id);
      return s;
    });
  };
  // Click the sub-council pill → cycle ANALYSIS → RISK → EXECUTION → ANALYSIS
  const cycleSubCouncil = (roleId) => {
    setRoleSubCouncils(prev => {
      const cur = prev[roleId] || "analysis";
      const idx = SUB_COUNCIL_CYCLE.indexOf(cur === "research" ? "analysis" : cur);
      const next = SUB_COUNCIL_CYCLE[(idx + 1) % SUB_COUNCIL_CYCLE.length];
      return { ...prev, [roleId]: next };
    });
  };
  const setRoleModel = (roleId, modelId) => {
    setRoleModels(prev => ({ ...prev, [roleId]: modelId }));
    setModelPickerFor(null);
  };

  const allSymbols = Object.keys(quotes);

  const submitToQueue = () => {
    if (!synthesis || !q) return;
    const txType = synthesis.decision.includes("BUY") || synthesis.decision === "ACCUMULATE" ? "BUY" : synthesis.decision === "HOLD" ? "BUY" : "SELL";
    const qty = Math.max(1, Math.round((synthesis.avgConv / 100) * 20));
    app.pushNews({ headline: `Council consensus · ${symbol} → ${synthesis.decision} (${verdicts.length} agents, ${Math.round(synthesis.agreement * 100)}% agreement)`, severity: "LOW", category: "CORPORATE", source: "Council", symbols: [symbol] });
    const newProp = {
      request_id: "council_" + Math.random().toString(36).slice(2, 8),
      source: "council",
      symbol, transaction_type: txType, quantity: qty,
      order_type: "LIMIT", product_type: "INTRADAY",
      reference_price: q.last, order_value: q.last * qty,
      rationale: `Council of ${verdicts.length} agents · ${Math.round(synthesis.agreement * 100)}% agreement · avg score ${synthesis.avg.toFixed(2)} · avg gut ${synthesis.avgGut.toFixed(0)}/100. Mode: ${mode.label}. Synth target ₹${synthesis.avgTarget.toFixed(2)}, stop ₹${synthesis.avgStop.toFixed(2)}.`,
      bias: { verdict: synthesis.avg > 0.1 ? "BULLISH" : synthesis.avg < -0.1 ? "BEARISH" : "NEUTRAL", score: synthesis.avg, rsi14: 55, ema20: q.last * 0.998, macd_hist: 1.2, atr14: q.last * 0.012 },
      risk_checks: [{ name: "Council quorum", status: synthesis.agreement >= 0.6 ? "pass" : "fail", detail: `${Math.round(synthesis.agreement * 100)}% ≥ 60%` }, { name: "OK", status: "pass", detail: "all pass" }],
      risk_passed: synthesis.agreement >= 0.6,
      status: "pending",
      created_at: new Date().toISOString(),
    };
    app.proposals.unshift(newProp); // hack — but it's a demo
  };

  // Reattach to a finished run — fetched as a one-shot replay (no SSE
  // tailing since status is already complete/error).  Used by the History
  // tab when the user clicks a past run.
  const replayRun = async (runId) => {
    setView("live");
    setRunning(false);
    setVerdicts([]);
    setProgress({});
    setChair(null);
    setChairStatus("idle");
    setPlan(null);
    setExpandedModel(null);
    setUsageTotals({ input: 0, output: 0, cache_read: 0, cache_write: 0, calls: 0 });
    try {
      // /stream with last_seq=-1 still works for completed runs — it replays
      // all stored events then exits cleanly because status is terminal.
      await attachToRun(runId, -1);
    } catch (e) {
      console.error("replay failed", e);
    }
  };

  return (
    <div style={{ display: "flex", flexDirection: "column", height: "100%", overflow: "hidden" }}>
      {view === "history" ? (
        <CouncilHistory onOpen={replayRun}/>
      ) : (
    <div style={{ display: "grid", gridTemplateColumns: "260px 1fr 320px", gap: 8, height: "100%", padding: 8, overflow: "hidden" }}>
      {/* LEFT: Configure the council
          flex layout: Council banner + Target + (velocity for trade) + Prompt Mode are auto-height,
          Roles + Agents takes the remaining space and scrolls. */}
      <div style={{ display: "flex", flexDirection: "column", gap: 8, minHeight: 0 }}>
        <div style={{ flex: "0 0 auto", display: "flex", alignItems: "center", gap: 8,
                      padding: "6px 8px", border: "1px solid var(--border)",
                      borderLeft: `3px solid ${cfg.color}`, background: "var(--bg-1)" }}>
          <span style={{ color: cfg.color, fontSize: 14 }}>{cfg.icon}</span>
          <div style={{ minWidth: 0, flex: 1 }}>
            <div style={{ fontSize: 10, fontWeight: 600, letterSpacing: "0.08em", color: cfg.color }}>{cfg.label}</div>
            <div className="faint" style={{ fontSize: 9, marginTop: 1 }}>{cfg.blurb}</div>
          </div>
        </div>
        {councilType === "trade" ? (
          <div style={{ flex: "0 0 auto" }}>
            <Panel title="Velocity" bodyFlush>
              <div style={{ padding: 6, display: "flex", flexDirection: "column", gap: 3 }}>
                {VELOCITY_OPTIONS.map(v => (
                  <button key={v.id} className={`mode-pick ${velocity === v.id ? "active" : ""}`} onClick={() => setVelocity(v.id)}>
                    <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
                      <span className="bright" style={{ fontSize: 11, fontWeight: 500 }}>{v.label}</span>
                    </div>
                    <div className="faint" style={{ fontSize: 10, marginTop: 2 }}>{v.desc}</div>
                  </button>
                ))}
              </div>
            </Panel>
          </div>
        ) : null}
        <div style={{ flex: "0 0 auto" }}>
          <Panel title="Target" bodyFlush>
            <div style={{ padding: 6 }}>
              {/* Compact: symbol + last in one row */}
              <div style={{ display: "grid", gridTemplateColumns: "1fr auto", gap: 8, alignItems: "center" }}>
                <select className="select" value={symbol} onChange={e => setSymbol(e.target.value)} style={{ fontSize: 11 }}>
                  {allSymbols.map(s => <option key={s}>{s}</option>)}
                </select>
                {q ? (
                  <div style={{ textAlign: "right", whiteSpace: "nowrap" }}>
                    <span className="bright tnum" style={{ fontSize: 14 }}>{q.last.toFixed(2)}</span>
                    <span className={`tnum ${q.last >= q.price ? "up" : "down"}`} style={{ fontSize: 10, marginLeft: 6 }}>
                      {q.last >= q.price ? "+" : ""}{((q.last - q.price) / q.price * 100).toFixed(2)}%
                    </span>
                  </div>
                ) : null}
              </div>
            </div>
          </Panel>
        </div>

        <div style={{ flex: "0 0 auto" }}>
          <Panel title="Prompt Mode" bodyFlush>
            <div style={{ padding: 6, display: "flex", flexDirection: "column", gap: 3,
                          maxHeight: "26vh", overflowY: "auto", overflowX: "hidden" }}>
              {PROMPT_MODES.map(m => (
                <button key={m.id} className={`mode-pick ${mode.id === m.id ? "active" : ""}`} onClick={() => setMode(m)}>
                  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
                    <span className="bright" style={{ fontSize: 11, fontWeight: 500 }}>{m.label}</span>
                    <span className="tnum faint" style={{ fontSize: 9 }}>
                      data {Math.round(m.weights.data * 100)} · gut {Math.round(m.weights.gut * 100)}
                    </span>
                  </div>
                  <div className="faint" style={{ fontSize: 10, marginTop: 2 }}>{m.desc}</div>
                  <div style={{ display: "flex", gap: 2, height: 3, marginTop: 5 }}>
                    <div style={{ flex: m.weights.data, background: "var(--cyan)" }}/>
                    <div style={{ flex: m.weights.gut, background: "var(--amber)" }}/>
                  </div>
                </button>
              ))}
            </div>
          </Panel>
        </div>

        <div style={{ flex: "1 1 auto", minHeight: 0 }}>
        <Panel title="Roles · Agents" tag={enabled.size + "/" + ROLES.length} bodyFlush>
          <div style={{ display: "flex", flexDirection: "column", gap: 8,
                        height: "100%", overflowY: "auto", overflowX: "hidden",
                        padding: 8 }}>
            {/* Group roles by their assigned sub-council so the desk
                structure is visible while you pick. */}
            {SUB_COUNCIL_CYCLE.map(sc => {
              const meta = SUB_COUNCIL_META[sc];
              const inSc = ROLES.filter(r => (roleSubCouncils[r.id] || r.sub_council) === sc);
              if (inSc.length === 0) return null;
              return (
                <div key={sc}>
                  <div style={{ fontSize: 9, letterSpacing: "0.1em", marginBottom: 3, padding: "0 2px",
                                display: "flex", alignItems: "baseline", gap: 6 }}>
                    <span style={{ color: meta.color, fontWeight: 700 }}>{meta.label}</span>
                    <span className="faint" style={{ fontSize: 8 }}>{meta.note}</span>
                    <span className="faint" style={{ fontSize: 8, marginLeft: "auto" }}>{inSc.length}</span>
                  </div>
                  {inSc.map(r => {
                    const on   = enabled.has(r.id);
                    const mid  = roleModels[r.id] || r.default_model;
                    const m    = MODELS.find(x => x.id === mid);
                    return (
                      <div key={r.id} style={{
                        display: "grid", gridTemplateColumns: "14px 16px 1fr", alignItems: "center", gap: 6,
                        padding: "4px 5px", background: on ? "var(--bg-0)" : "transparent",
                        border: "1px solid", borderColor: on ? "var(--border)" : "transparent",
                        marginBottom: 2,
                      }}>
                        <input type="checkbox" className="checkbox" checked={on} onChange={() => toggleRole(r.id)}/>
                        <span style={{ color: r.color, fontSize: 13, textAlign: "center" }}>{r.icon}</span>
                        <div style={{ minWidth: 0 }}>
                          <div style={{ display: "flex", alignItems: "baseline", gap: 6, flexWrap: "wrap" }}>
                            <span className={on ? "bright" : "dim"} style={{ fontSize: 10, fontWeight: 500 }}>{r.name}</span>
                            {/* Only LLM-brand badge — the sub-council is already shown in
                                the section header above, so the per-row tag is redundant.
                                A small ↔ button lets you move the role to a different
                                sub-council if needed. */}
                            <button onClick={(e) => { e.stopPropagation(); setModelPickerFor(modelPickerFor === r.id ? null : r.id); }}
                              title={`Run via ${m ? m.vendor : '—'} — click to change brain`}
                              style={{ fontSize: 8, padding: "1px 5px", background: "var(--bg-2)", border: "1px solid var(--border)",
                                       color: m ? m.color : "var(--fg-faint)", cursor: "pointer", letterSpacing: "0.04em" }}>
                              <span style={{ marginRight: 3 }}>{m ? m.icon : "?"}</span>
                              {m ? m.vendor : "—"}
                            </button>
                            <button onClick={(e) => { e.stopPropagation(); cycleSubCouncil(r.id); }}
                              title="Move to different sub-council"
                              style={{ fontSize: 9, padding: "0 4px", background: "transparent", border: "1px solid var(--grid)",
                                       color: "var(--fg-faint)", cursor: "pointer", lineHeight: 1.2 }}>↔</button>
                          </div>
                          <div className="faint" style={{ fontSize: 9, marginTop: 1 }}>{r.short_focus}</div>
                          {modelPickerFor === r.id ? (
                            <div style={{ display: "flex", flexWrap: "wrap", gap: 3, marginTop: 4, padding: "4px 5px",
                                           border: "1px solid var(--amber-dim)", background: "var(--bg-2)" }}>
                              {MODELS.map(opt => (
                                <button key={opt.id} onClick={() => setRoleModel(r.id, opt.id)}
                                  className={`chip ${mid === opt.id ? "active" : ""}`}
                                  style={{ fontSize: 9, padding: "1px 5px", color: mid === opt.id ? undefined : opt.color,
                                            borderColor: mid === opt.id ? undefined : opt.color }}>
                                  <span style={{ marginRight: 3 }}>{opt.icon}</span>{opt.vendor}
                                </button>
                              ))}
                            </div>
                          ) : null}
                        </div>
                      </div>
                    );
                  })}
                </div>
              );
            })}
          </div>
        </Panel>
        </div>{/* end roles flex wrapper */}

        <div style={{ flex: "0 0 auto" }}>
          <button className="btn btn-primary" style={{ width: "100%", padding: "8px 12px", fontSize: 11, letterSpacing: "0.1em" }} onClick={runCouncil} disabled={running || enabled.size === 0}>
            {running ? "◉ AGENTS DELIBERATING…" : "▸ CONVENE COUNCIL"}
          </button>
        </div>
      </div>

      {/* CENTER: Verdicts */}
      <Panel
        title={verdicts ? `Council · ${symbol} · ${mode.label}` : "Council"}
        tag={verdicts?.length || ""}
        actions={
          <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
            <label style={{ fontSize: 10, display: "flex", alignItems: "center", gap: 4 }}>
              <input type="checkbox" className="checkbox" checked={autoMode} onChange={e => setAutoMode(e.target.checked)}/>
              auto-run every 5m
            </label>
          </div>
        }
        bodyFlush
      >
        <div style={{ overflow: "auto", height: "100%" }}>
          <div style={{ padding: 8, display: "flex", flexDirection: "column", gap: 6 }}>
            {/* Live progress panel — ALWAYS visible.
                Before a run, shows the planned roster (enabled personas
                grouped by sub-council, status="idle") so the user can see
                who will deliberate and which sub-council each belongs to.
                During a run, the same rows tick through their 3-step pips
                in real time. */}
            <CostPanel usage={usageTotals} isRunning={running} chairStatus={chairStatus}/>
            <CouncilProgress
              progress={Object.keys(progress).length > 0
                        ? progress
                        : _buildIdleRoster(enabled, roleSubCouncils, roleModels)}
              plan={plan}
              chairStatus={chairStatus}
              isRunning={running}
              verdicts={verdicts}
              gatherStage={gatherStage}
              vendorActivity={vendorActivity}
            />
            {(running || activityLog.length > 0) ? (
              <BackendActivityPanel
                log={activityLog}
                gatherStage={gatherStage}
                vendorActivity={vendorActivity}
              />
            ) : null}
            {verdicts && verdicts.length > 0 ? (
              <SubCouncilGroups verdicts={verdicts} expandedModel={expandedModel} setExpandedModel={setExpandedModel}/>
            ) : null}
            {!verdicts && !running ? (
              <div style={{ padding: 10, textAlign: "center", color: "var(--fg-dim)", fontSize: 10, borderLeft: "2px solid var(--cyan)", background: "var(--bg-0)" }}>
                Click <span className="amber">▸ CONVENE COUNCIL</span> to start.  Each persona above will run a 3-step chain
                (analyse → critique → decide); the Chair then synthesises a final trade plan.
              </div>
            ) : null}
          </div>
        </div>
      </Panel>

      {/* RIGHT: Synthesis */}
      <div style={{ display: "flex", flexDirection: "column", gap: 8, minHeight: 0, overflow: "auto" }}>
        {/* Chair card — shown whenever a real run produces a Chair verdict */}
        {chair || chairStatus !== "idle" ? (
          <ChairCard chair={chair} chairStatus={chairStatus} q={q}/>
        ) : null}
        {/* Legacy synthesis aggregates the verdicts list — when verdicts are
            stale (run in flight), grey it out so the user sees it's
            recomputing. */}
        <Panel title="Chief Trader · Synthesis"
          tag={(verdicts || []).some(v => v.stale) ? "refreshing" : null}>
          {synthesis ? (
            <div style={{
              filter: (verdicts || []).some(v => v.stale) ? "grayscale(1) brightness(0.55)" : "none",
              opacity: (verdicts || []).some(v => v.stale) ? 0.5 : 1,
              transition: "filter 200ms ease, opacity 200ms ease",
            }}>
              <div style={{ padding: "10px 0", textAlign: "center", borderBottom: "1px solid var(--border)" }}>
                <div className="h-xxs">CONSENSUS DECISION</div>
                <div style={{ fontSize: 28, fontWeight: 600, letterSpacing: "0.05em", marginTop: 4 }}
                     className={synthesis.decision === "BUY" || synthesis.decision === "ACCUMULATE" ? "up" : synthesis.decision === "SELL" || synthesis.decision === "REDUCE" ? "down" : "bright"}>
                  {synthesis.decision}
                </div>
                <div className="faint" style={{ fontSize: 10, marginTop: 2 }}>
                  {Math.round(synthesis.agreement * 100)}% agreement · {verdicts.length} agents
                </div>
              </div>
              <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 4, marginTop: 10 }}>
                <KpiBox label="Avg Score"  val={(synthesis.avg > 0 ? "+" : "") + synthesis.avg.toFixed(2)} tone={synthesis.avg > 0 ? "up" : "down"}/>
                <KpiBox label="Avg Gut"    val={synthesis.avgGut.toFixed(0) + "/100"} tone={synthesis.avgGut > 50 ? "up" : "down"}/>
                <KpiBox label="Conviction" val={synthesis.avgConv.toFixed(0) + "%"}/>
                <KpiBox label="Agreement"  val={Math.round(synthesis.agreement * 100) + "%"} tone={synthesis.agreement >= 0.66 ? "up" : synthesis.agreement >= 0.5 ? "amber" : "down"}/>
              </div>
              <div style={{ marginTop: 10 }}>
                <div className="h-xxs">VOTE SPLIT</div>
                <div style={{ display: "flex", height: 10, marginTop: 5, gap: 1 }}>
                  <div style={{ flex: synthesis.buys,  background: "var(--up)" }} title={`${synthesis.buys} BUY/ACCUM`}/>
                  <div style={{ flex: synthesis.holds, background: "var(--fg-faint)" }} title={`${synthesis.holds} HOLD`}/>
                  <div style={{ flex: synthesis.sells, background: "var(--down)" }} title={`${synthesis.sells} SELL/REDUCE`}/>
                </div>
                <div style={{ display: "flex", justifyContent: "space-between", fontSize: 9, marginTop: 3 }}>
                  <span className="up">{synthesis.buys} BUY</span>
                  <span className="dim">{synthesis.holds} HOLD</span>
                  <span className="down">{synthesis.sells} SELL</span>
                </div>
              </div>
              <dl className="kv" style={{ marginTop: 12, gridTemplateColumns: "auto 1fr", fontSize: 10 }}>
                <dt>Target (avg)</dt><dd className="up">₹{synthesis.avgTarget.toFixed(2)}</dd>
                <dt>Stop (avg)</dt><dd className="down">₹{synthesis.avgStop.toFixed(2)}</dd>
                <dt>R:R</dt><dd>{q ? (Math.abs(synthesis.avgTarget - q.last) / Math.abs(q.last - synthesis.avgStop)).toFixed(2) : "—"}</dd>
              </dl>
              <button className="btn btn-primary" style={{ width: "100%", marginTop: 12 }} disabled={synthesis.agreement < 0.5 || !q} onClick={submitToQueue}>
                {synthesis.agreement < 0.5 ? "⚠ NO QUORUM" : "▸ SEND TO PROPOSAL QUEUE"}
              </button>
            </div>
          ) : (
            <div style={{ padding: 20, textAlign: "center", color: "var(--fg-dim)", fontSize: 11 }}>
              Convene the council to generate a consensus decision.
            </div>
          )}
        </Panel>

        {synthesis ? (
          <Panel title="Agreement Matrix" bodyFlush>
            <div style={{ padding: 8 }}>
              <div className="faint" style={{ fontSize: 9, marginBottom: 6 }}>Score vs. Gut — each agent plotted</div>
              <ScatterChart verdicts={verdicts}/>
            </div>
          </Panel>
        ) : null}
      </div>
    </div>
      )}
    </div>
  );
}

// Tab strip: Live (the deliberation board) | History (past runs).
function CouncilTabs({ view, setView }) {
  const Tab = ({ id, label }) => (
    <div onClick={() => setView(id)} style={{
      padding: "6px 14px", cursor: "pointer", fontSize: 11,
      letterSpacing: "0.08em",
      color: view === id ? "var(--amber)" : "var(--fg-dim)",
      borderBottom: view === id ? "2px solid var(--amber)" : "2px solid transparent",
    }}>{label}</div>
  );
  return (
    <div style={{
      display: "flex", alignItems: "center", gap: 4,
      borderBottom: "1px solid var(--border)", padding: "0 8px",
      flex: "0 0 auto", background: "var(--bg-1)",
    }}>
      <span className="h-xxs" style={{ marginRight: 8 }}>COUNCIL</span>
      <Tab id="live"    label="LIVE"/>
      <Tab id="history" label="HISTORY"/>
    </div>
  );
}

// History — past runs newest-first.  Click a row to replay it (events
// stream back from SQLite into the existing reducer).
function CouncilHistory({ onOpen }) {
  const [rows, setRows] = useState(null);
  const [error, setError] = useState(null);
  useEffect(() => {
    (async () => {
      try {
        const r = await fetch("/api/council/runs?limit=100");
        if (!r.ok) throw new Error("HTTP " + r.status);
        const j = await r.json();
        setRows(j.runs || []);
      } catch (e) { setError(e.message || String(e)); }
    })();
  }, []);
  if (error) return <div style={{ padding: 12, color: "var(--down)", fontSize: 11 }}>Failed to load history: {error}</div>;
  if (rows === null) return <div style={{ padding: 12, color: "var(--fg-dim)", fontSize: 11 }}>Loading history…</div>;
  if (rows.length === 0) return <div style={{ padding: 12, color: "var(--fg-dim)", fontSize: 11 }}>No past runs yet — click ▸ CONVENE COUNCIL on the Live tab to start one.</div>;
  return (
    <div style={{ overflow: "auto", padding: 8 }}>
      <div style={{
        display: "grid",
        gridTemplateColumns: "150px 110px 90px 90px 110px 110px 1fr 80px",
        gap: 0, fontSize: 10, letterSpacing: "0.06em",
        color: "var(--fg-dim)", padding: "6px 8px",
        borderBottom: "1px solid var(--border)", background: "var(--bg-0)",
      }}>
        <span>WHEN</span><span>SYMBOL</span><span>MODE</span><span>STATUS</span>
        <span>DECISION</span><span>CONVICTION</span><span>RUN ID</span><span style={{ textAlign: "right" }}>OPEN</span>
      </div>
      {rows.map(r => {
        const cs = r.chair_summary || {};
        const action = (cs.action || "").toUpperCase();
        const isBuy  = action.includes("BUY") || action === "ACCUMULATE";
        const isSell = action.includes("SELL") || action === "REDUCE";
        const statusColor = r.status === "complete" ? "var(--up)"
                          : r.status === "error"    ? "var(--down)"
                          :                           "var(--amber)";
        return (
          <div key={r.run_id}
            onClick={() => onOpen(r.run_id)}
            style={{
              display: "grid",
              gridTemplateColumns: "150px 110px 90px 90px 110px 110px 1fr 80px",
              gap: 0, fontSize: 10, padding: "8px",
              borderBottom: "1px solid var(--grid)", cursor: "pointer",
              alignItems: "center",
            }}>
            <span className="tnum" style={{ color: "var(--fg-dim)" }}>
              {r.created_at?.replace("T", " ").slice(0, 19)}
            </span>
            <span className="bright">{r.symbol}</span>
            <span style={{ color: "var(--fg-dim)" }}>{r.mode}</span>
            <span style={{ color: statusColor, textTransform: "uppercase" }}>{r.status}</span>
            <span className={isBuy ? "up" : isSell ? "down" : "bright"}
                  style={{ fontWeight: 600 }}>
              {action || (r.status === "error" ? "—" : "…")}
            </span>
            <span className="tnum">{cs.conviction != null ? cs.conviction + "%" : "—"}</span>
            <span className="faint" style={{ fontFamily: "monospace", fontSize: 9 }}>
              {r.run_id.slice(0, 12)}…
            </span>
            <span style={{ textAlign: "right", color: "var(--cyan)" }}>OPEN ▸</span>
          </div>
        );
      })}
    </div>
  );
}

function VerdictCard({ v, expanded, onToggle }) {
  const { model, action, conviction, finalScore, dataBias, gutBias, gutScore, priceTarget, stopLoss, horizon, sizeSuggest, reasoning, latencyMs, tokens } = v;
  const isBuy = action.includes("BUY") || action === "ACCUMULATE";
  const isSell = action.includes("SELL") || action === "REDUCE";
  const fallback = v.usedVendor === "anthropic-fallback";
  return (
    <div style={{
      border: "1px solid var(--border)", borderLeft: `3px solid ${model.color}`,
      background: "var(--bg-1)",
      // Grayscale + low opacity makes "stale" obviously inactive even when
      // the card has high-contrast pills (ACCUMULATE / 84% / etc).
      filter: v.stale ? "grayscale(1) brightness(0.55)" : "none",
      opacity: v.stale ? 0.5 : 1,
      transition: "filter 200ms ease, opacity 200ms ease",
    }}>
      <div onClick={onToggle} style={{ padding: 8, cursor: "pointer", display: "grid", gridTemplateColumns: "24px 200px 80px 1fr auto auto auto", gap: 10, alignItems: "center" }}>
        <span style={{ color: model.color, fontSize: 16 }}>{model.icon}</span>
        <div>
          <div className="bright" style={{ fontSize: 11, fontWeight: 500 }}>
            {model.name}
            {v.stale ? <span className="pill pill-dim"      style={{ fontSize: 8, marginLeft: 5 }} title="Refreshing — replaced when this role's new verdict arrives">REFRESHING</span> : null}
            {v.error ? <span className="pill pill-red-solid" style={{ fontSize: 8, marginLeft: 5 }}>ERROR</span> : null}
            {fallback ? <span className="pill pill-cyan"    style={{ fontSize: 8, marginLeft: 5 }} title={`No ${model.vendor} key — running through Claude with this persona`}>VIA CLAUDE</span> : null}
            {v.live && !fallback && !v.stale ? <span className="pill pill-up" style={{ fontSize: 8, marginLeft: 5 }}>LIVE</span> : null}
          </div>
          <div className="faint" style={{ fontSize: 9 }}>{fallback ? `Claude (${model.vendor} key missing)` : model.vendor} · {horizon} · 3-step chain</div>
        </div>
        <span className={`pill ${isBuy ? "pill-up" : isSell ? "pill-down" : "pill-dim"}`} style={{ justifySelf: "start", fontWeight: 600 }}>{action}</span>
        <div>
          <div className="faint" style={{ fontSize: 9, marginBottom: 2 }}>DATA {dataBias >= 0 ? "+" : ""}{dataBias.toFixed(2)} · GUT {gutBias >= 0 ? "+" : ""}{gutBias.toFixed(2)}</div>
          <BlendBar data={dataBias} gut={gutBias} final={finalScore}/>
        </div>
        <div style={{ textAlign: "right" }}>
          <div className="h-xxs" style={{ fontSize: 8 }}>CONV</div>
          <div className={`tnum ${conviction > 60 ? "bright" : "dim"}`} style={{ fontSize: 14, fontWeight: 500 }}>{conviction}%</div>
        </div>
        <div style={{ textAlign: "right", borderLeft: "1px solid var(--grid)", paddingLeft: 10 }}>
          <div className="h-xxs" style={{ fontSize: 8 }}>GUT</div>
          <div className="amber tnum" style={{ fontSize: 14, fontWeight: 500 }}>{gutScore}</div>
        </div>
        <span className="faint" style={{ fontSize: 14 }}>{expanded ? "▾" : "▸"}</span>
      </div>
      {expanded ? (
        <div style={{ padding: "0 10px 10px 34px", fontSize: 11, lineHeight: 1.6 }}>
          <div style={{ padding: 8, background: "var(--bg-0)", borderLeft: `2px solid ${model.color}` }}>
            <div className="h-xxs" style={{ marginBottom: 4 }}>FINAL DECISION (step 3 reasoning)</div>
            {reasoning}
          </div>
          {v.keyRisks && v.keyRisks.length ? (
            <div style={{ marginTop: 6 }}>
              <div className="h-xxs" style={{ marginBottom: 3 }}>KEY RISKS (from critique)</div>
              <ul style={{ margin: 0, paddingLeft: 18, fontSize: 10 }}>
                {v.keyRisks.map((r, i) => <li key={i} className="down">{r}</li>)}
              </ul>
            </div>
          ) : null}
          {v.swingFactors && v.swingFactors.length ? (
            <div style={{ marginTop: 6 }}>
              <div className="h-xxs" style={{ marginBottom: 3 }}>WHAT WOULD CHANGE THIS CALL</div>
              <ul style={{ margin: 0, paddingLeft: 18, fontSize: 10 }}>
                {v.swingFactors.map((r, i) => <li key={i} className="amber">{r}</li>)}
              </ul>
            </div>
          ) : null}
          {/* Chain transcript — only when we have real chain data */}
          {Array.isArray(v.chain) && v.chain.length ? (
            <div style={{ marginTop: 8, border: "1px solid var(--grid)", background: "var(--bg-0)" }}>
              <div className="h-xxs" style={{ padding: "5px 8px", borderBottom: "1px solid var(--grid)" }}>
                CHAIN TRANSCRIPT · 3 STEPS
              </div>
              {v.systemPrompt ? (
                <details style={{ borderBottom: "1px solid var(--grid)" }}>
                  <summary style={{ padding: "4px 8px", cursor: "pointer", fontSize: 10, display: "flex", justifyContent: "space-between" }}>
                    <span className="amber" style={{ letterSpacing: "0.08em" }}>SYSTEM PROMPT (shared across all steps)</span>
                    <span className="faint">{v.systemPrompt.length} chars</span>
                  </summary>
                  <pre style={{ padding: "4px 10px 8px", fontSize: 10, lineHeight: 1.5, whiteSpace: "pre-wrap", color: "var(--fg-dim)", margin: 0 }}>
                    {v.systemPrompt}
                  </pre>
                </details>
              ) : null}
              {v.chain.map((c, i) => (
                <details key={i} style={{ borderTop: i ? "1px solid var(--grid)" : "none" }}>
                  <summary style={{ padding: "4px 8px", cursor: "pointer", fontSize: 10, display: "flex", justifyContent: "space-between" }}>
                    <span className="bright" style={{ letterSpacing: "0.08em" }}>{c.step.toUpperCase()}</span>
                    <span className="faint">{c.latency_ms}ms · req {(c.user || "").length}c · resp {(c.text || "").length}c</span>
                  </summary>
                  {c.user ? (
                    <div style={{ borderTop: "1px solid var(--grid)" }}>
                      <div className="h-xxs" style={{ padding: "4px 10px", color: "var(--cyan)" }}>REQUEST (user prompt)</div>
                      <pre style={{ padding: "0 10px 6px", fontSize: 10, lineHeight: 1.5, whiteSpace: "pre-wrap", color: "var(--fg-dim)", margin: 0 }}>
                        {c.user}
                      </pre>
                    </div>
                  ) : null}
                  <div style={{ borderTop: "1px solid var(--grid)" }}>
                    <div className="h-xxs" style={{ padding: "4px 10px", color: "var(--up)" }}>RESPONSE</div>
                    <pre style={{ padding: "0 10px 8px", fontSize: 10, lineHeight: 1.5, whiteSpace: "pre-wrap", color: "var(--fg-dim)", margin: 0 }}>
                      {c.text}
                    </pre>
                  </div>
                </details>
              ))}
            </div>
          ) : null}
          <div style={{ display: "grid", gridTemplateColumns: "repeat(5, 1fr)", gap: 4, marginTop: 8 }}>
            <KpiBox label="Target"   val={"₹" + priceTarget.toFixed(2)} tone="up"/>
            <KpiBox label="Stop"     val={"₹" + stopLoss.toFixed(2)} tone="down"/>
            <KpiBox label="Size"     val={sizeSuggest + "%"}/>
            <KpiBox label="Latency"  val={latencyMs + "ms"}/>
            <KpiBox label="Vendor"   val={v.usedVendor || model.vendor}/>
          </div>
          <div className="faint" style={{ fontSize: 9, marginTop: 6, fontStyle: "italic" }}>{model.style}</div>
        </div>
      ) : null}
    </div>
  );
}

function BlendBar({ data, gut, final }) {
  const toPct = (v) => ((v + 1) / 2) * 100;
  return (
    <div style={{ position: "relative", height: 6, background: "var(--bg-0)", border: "1px solid var(--border)" }}>
      <div style={{ position: "absolute", left: "50%", top: 0, bottom: 0, width: 1, background: "var(--fg-faint)" }}/>
      <div style={{ position: "absolute", left: toPct(data) - 0.5 + "%", top: -2, width: 6, height: 10, background: "var(--cyan)" }} title={`data ${data.toFixed(2)}`}/>
      <div style={{ position: "absolute", left: toPct(gut) - 0.5 + "%", top: -2, width: 6, height: 10, background: "var(--amber)" }} title={`gut ${gut.toFixed(2)}`}/>
      <div style={{ position: "absolute", left: toPct(final) - 1 + "%", top: -3, width: 8, height: 12, background: final > 0 ? "var(--up)" : "var(--down)", border: "1px solid var(--fg-bright)" }} title={`final ${final.toFixed(2)}`}/>
    </div>
  );
}

function KpiBox({ label, val, tone }) {
  const c = tone === "up" ? "up" : tone === "down" ? "down" : tone === "amber" ? "amber" : "bright";
  return (
    <div style={{ padding: 5, background: "var(--bg-0)", border: "1px solid var(--border)" }}>
      <div className="h-xxs" style={{ fontSize: 8 }}>{label}</div>
      <div className={`tnum ${c}`} style={{ fontSize: 12, marginTop: 1 }}>{val}</div>
    </div>
  );
}

function ScatterChart({ verdicts }) {
  const w = 280, h = 160;
  const x = (v) => ((v + 1) / 2) * (w - 20) + 10; // score -1..1
  const y = (g) => h - 10 - (g / 100) * (h - 20);  // gut 0..100
  return (
    <svg viewBox={`0 0 ${w} ${h}`} width="100%" height={h} style={{ background: "var(--bg-0)", border: "1px solid var(--border)" }}>
      {/* quadrant lines */}
      <line x1={x(0)} y1={10} x2={x(0)} y2={h - 10} stroke="var(--grid)"/>
      <line x1={10} y1={y(50)} x2={w - 10} y2={y(50)} stroke="var(--grid)"/>
      {/* labels */}
      <text x={12} y={18} fontSize="8" fill="var(--fg-faint)">BEARISH · HIGH GUT</text>
      <text x={w - 12} y={18} fontSize="8" fill="var(--fg-faint)" textAnchor="end">BULLISH · HIGH GUT</text>
      <text x={12} y={h - 14} fontSize="8" fill="var(--fg-faint)">BEARISH · LOW GUT</text>
      <text x={w - 12} y={h - 14} fontSize="8" fill="var(--fg-faint)" textAnchor="end">BULLISH · LOW GUT</text>
      {verdicts.map(v => (
        <g key={v.model.id}>
          <circle cx={x(v.finalScore)} cy={y(v.gutScore)} r={5 + v.conviction / 25}
                  fill={v.model.color} opacity="0.35" stroke={v.model.color} strokeWidth="1.5"/>
          <text x={x(v.finalScore) + 8} y={y(v.gutScore) + 3} fontSize="9" fill={v.model.color}>{v.model.icon} {v.model.id}</text>
        </g>
      ))}
    </svg>
  );
}

function EmptyCouncil() {
  return (
    <div style={{ padding: 30, textAlign: "center", color: "var(--fg-dim)" }}>
      <div style={{ fontSize: 36, color: "var(--fg-faint)", marginBottom: 10 }}>◆ ✦ ◈ ▲ ✕ ◐</div>
      <div className="bright" style={{ fontSize: 13, marginBottom: 6 }}>Council is ready.</div>
      <div style={{ fontSize: 11, lineHeight: 1.6 }}>
        Each selected agent will independently analyze the target symbol<br/>
        blending indicators ( <span className="cyan">data</span> ) with intuition ( <span className="amber">gut feel</span> ),<br/>
        then the Chief Trader synthesizes their verdicts into a single decision.
      </div>
    </div>
  );
}

// ===== API KEYS (settings sub-page) =====
function ApiKeysPage({ app }) {
  // Real backend-backed state — no mock data.  Each row reflects what the
  // server reports for app_settings DB + .env presence.  TEST does a real
  // network ping to the provider.
  const [keys, setKeys] = useState([]);
  const [loading, setLoading] = useState(true);
  const [loadError, setLoadError] = useState(null);
  const [reveal, setReveal] = useState(false);  // unused — keys are masked server-side
  const [adding, setAdding] = useState(false);
  const [draftKey, setDraftKey] = useState("");
  const [draftEnv, setDraftEnv] = useState("ANTHROPIC_API_KEY");
  const [savingDraft, setSavingDraft] = useState(false);
  const [importing, setImporting] = useState(false);
  const [lastTest, setLastTest] = useState({});  // id -> {ok, http, latency_ms, error}

  const VENDOR_LABELS = {
    anthropic: "Anthropic", openai: "OpenAI", google: "Google",
    perplexity: "Perplexity", xai: "xAI", deepseek: "DeepSeek",
  };
  const ENV_OPTIONS = [
    { env: "ANTHROPIC_API_KEY", label: "Anthropic (Claude)" },
    { env: "OPENAI_API_KEY",    label: "OpenAI (GPT)" },
    { env: "GOOGLE_API_KEY",    label: "Google (Gemini)" },
    { env: "PERPLEXITY_API_KEY",label: "Perplexity (Sonar)" },
    { env: "XAI_API_KEY",       label: "xAI (Grok)" },
    { env: "DEEPSEEK_API_KEY",  label: "DeepSeek" },
  ];

  const reload = async () => {
    try {
      const r = await fetch("/api/llm-keys");
      const j = await r.json();
      if (j.ok) {
        setKeys((j.keys || []).map(k => ({
          ...k,
          vendorLabel: VENDOR_LABELS[k.vendor] || k.vendor,
          status: k.configured ? (k.source === "env" ? "env-only" : "configured") : "missing",
        })));
        setLoadError(null);
      } else setLoadError(j.error || "load failed");
    } catch (e) { setLoadError(String(e)); }
    finally { setLoading(false); }
  };
  useEffect(() => { reload(); }, []);

  const testKey = async (id) => {
    setKeys(prev => prev.map(k => k.id === id ? { ...k, status: "testing" } : k));
    try {
      const r = await fetch(`/api/llm-keys/${id}/test`, { method: "POST" });
      const j = await r.json();
      setLastTest(prev => ({ ...prev, [id]: j }));
      setKeys(prev => prev.map(k => k.id === id ? {
        ...k,
        status: j.ok ? "active" : (j.status === "missing" ? "missing" : "invalid"),
      } : k));
    } catch (e) {
      setLastTest(prev => ({ ...prev, [id]: { ok: false, error: String(e) } }));
      setKeys(prev => prev.map(k => k.id === id ? { ...k, status: "invalid" } : k));
    }
  };

  const saveKey = async () => {
    if (!draftKey || !draftEnv) return;
    setSavingDraft(true);
    try {
      const r = await fetch(`/api/settings/${draftEnv}`, {
        method: "PUT",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ value: draftKey, is_secret: true }),
      });
      if (!r.ok) throw new Error(`save failed (HTTP ${r.status})`);
      setDraftKey(""); setAdding(false);
      await reload();
    } catch (e) { alert("Save failed: " + e); }
    finally { setSavingDraft(false); }
  };

  const removeKey = async (envName, id) => {
    if (!confirm(`Remove ${envName}?`)) return;
    try {
      await fetch(`/api/settings/${envName}`, { method: "DELETE" });
      await reload();
      setLastTest(prev => { const c = { ...prev }; delete c[id]; return c; });
    } catch (e) { alert("Delete failed: " + e); }
  };

  const importFromEnv = async () => {
    setImporting(true);
    try {
      const r = await fetch("/api/llm-keys/import-from-env", { method: "POST" });
      const j = await r.json();
      if (j.ok) {
        await reload();
        const n = (j.imported || []).length;
        alert(n ? `Imported ${n} key(s) from .env: ${j.imported.join(", ")}`
                : "Nothing to import — no .env-only keys found.");
      }
    } catch (e) { alert("Import failed: " + e); }
    finally { setImporting(false); }
  };

  const activeCount = keys.filter(k => k.configured).length;

  return (
    <div style={{ display: "grid", gridTemplateColumns: "1fr 320px", gap: 8, height: "100%", padding: 8, overflow: "hidden" }}>
      <Panel title="LLM API Keys" tag={activeCount}
        actions={<>
          <button className="btn btn-xs" disabled={importing} onClick={importFromEnv}>
            {importing ? "IMPORTING…" : "IMPORT .ENV"}
          </button>
          <button className="btn btn-xs" onClick={reload} style={{ marginLeft: 4 }}>RELOAD</button>
          <button className="btn btn-xs btn-primary" style={{ marginLeft: 4 }} onClick={() => setAdding(true)}>+ ADD KEY</button>
        </>} bodyFlush>
        <div style={{ overflow: "auto", height: "100%" }}>
          {loading ? (
            <div style={{ padding: 18, fontSize: 11 }} className="faint">Loading real key status…</div>
          ) : loadError ? (
            <div style={{ padding: 18, fontSize: 11 }} className="down">Failed to load: {loadError}</div>
          ) : (
          <table className="tbl tbl-compact">
            <thead><tr>
              <th>Vendor</th><th>Model</th><th>Env Var</th><th>Key</th><th>Source</th><th>Status</th><th>Last Test</th><th/>
            </tr></thead>
            <tbody>
              {keys.map(k => {
                const t = lastTest[k.id];
                return (
                <tr key={k.id}>
                  <td>
                    <div className="bright" style={{ fontWeight: 500 }}>{k.vendorLabel}</div>
                    <div className="faint" style={{ fontSize: 9 }}>{k.id}</div>
                  </td>
                  <td style={{ fontSize: 10 }}>{k.model}</td>
                  <td style={{ fontSize: 10 }} className="faint">{k.key_env}</td>
                  <td style={{ fontSize: 10, fontFamily: "var(--mono, monospace)" }}>
                    {k.configured ? k.key_masked : <span className="faint">—</span>}
                  </td>
                  <td>
                    {k.source === "db"   ? <span className="pill pill-up">DB</span>
                    : k.source === "env" ? <span className="pill pill-cyan" title="In .env but NOT yet in DB — council won't use it. Click IMPORT .ENV.">ENV ONLY</span>
                    : <span className="pill pill-dim">—</span>}
                  </td>
                  <td>
                    {k.status === "active"     ? <span className="pill pill-up"><span className="dot dot-live" style={{ width: 4, height: 4 }}/> ACTIVE</span>
                    : k.status === "testing"   ? <span className="pill pill-cyan">◉ TESTING…</span>
                    : k.status === "invalid"   ? <span className="pill pill-down" title={t?.error || ""}>✗ INVALID{t?.http ? ` (${t.http})` : ""}</span>
                    : k.status === "configured"? <span className="pill pill-dim">UNTESTED</span>
                    : k.status === "env-only"  ? <span className="pill pill-cyan">UNTESTED</span>
                    : <span className="pill pill-dim">— MISSING</span>}
                  </td>
                  <td style={{ fontSize: 10 }} className="faint">
                    {t ? (t.ok ? `${t.latency_ms}ms` : (t.error ? String(t.error).slice(0, 40) : "fail")) : "—"}
                  </td>
                  <td>
                    <div style={{ display: "flex", gap: 3 }}>
                      <button className="btn btn-xs" disabled={!k.configured} onClick={() => testKey(k.id)}>TEST</button>
                      <button className="btn btn-xs btn-ghost" disabled={k.source !== "db"} style={{ color: "var(--down)" }} onClick={() => removeKey(k.key_env, k.id)}>✕</button>
                    </div>
                  </td>
                </tr>
              );})}
            </tbody>
          </table>
          )}
        </div>
      </Panel>

      <div style={{ display: "flex", flexDirection: "column", gap: 8, minHeight: 0 }}>
        <Panel title="Status · live">
          <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 4 }}>
            <KpiBox label="Configured" val={String(activeCount) + " / " + String(keys.length)}/>
            <KpiBox label="Tested OK" val={String(Object.values(lastTest).filter(t => t.ok).length)} tone="up"/>
          </div>
          <div style={{ marginTop: 10, fontSize: 10, lineHeight: 1.5 }} className="faint">
            <div>· <b>DB</b> = stored in app_settings (council uses it).</div>
            <div>· <b>ENV ONLY</b> = present in .env but not yet copied to DB. Click <b>IMPORT .ENV</b>.</div>
            <div>· <b>TEST</b> issues a real network call to the provider.</div>
          </div>
        </Panel>

        <Panel title="Routing Rules">
          <div className="faint" style={{ fontSize: 10, marginBottom: 8 }}>How trades are routed when multiple keys are active.</div>
          <dl className="kv" style={{ fontSize: 10 }}>
            <dt>Default agent</dt><dd>Claude Opus 4.5</dd>
            <dt>Council size</dt><dd>min 3 agents</dd>
            <dt>Quorum</dt><dd>60% agreement</dd>
            <dt>On rate-limit</dt><dd>fallback to Claude (Anthropic)</dd>
          </dl>
        </Panel>
      </div>

      {adding ? (
        <div className="modal-backdrop" onClick={() => setAdding(false)}>
          <div className="modal" onClick={e => e.stopPropagation()} style={{ minWidth: 420 }}>
            <div className="panel-header" style={{ padding: "10px 14px" }}><span className="title">ADD API KEY</span><button className="btn btn-xs btn-ghost" onClick={() => setAdding(false)}>✕</button></div>
            <div style={{ padding: 14 }}>
              <div className="label">Vendor</div>
              <select className="select" value={draftEnv} onChange={e => setDraftEnv(e.target.value)}>
                {ENV_OPTIONS.map(o => <option key={o.env} value={o.env}>{o.label}</option>)}
              </select>
              <div className="label" style={{ marginTop: 10 }}>API Key</div>
              <input className="input" placeholder="sk-…" value={draftKey} onChange={e => setDraftKey(e.target.value)} autoFocus/>
              <div className="faint" style={{ fontSize: 10, marginTop: 10, lineHeight: 1.5 }}>
                Stored in app_settings (SQLite) and used immediately by the council. Never sent anywhere except the chosen provider when you convene.
              </div>
              <div style={{ display: "flex", justifyContent: "flex-end", gap: 6, marginTop: 14 }}>
                <button className="btn" onClick={() => setAdding(false)}>CANCEL</button>
                <button className="btn btn-primary" disabled={!draftKey || savingDraft} onClick={saveKey}>
                  {savingDraft ? "SAVING…" : "SAVE KEY"}
                </button>
              </div>
            </div>
          </div>
        </div>
      ) : null}
    </div>
  );
}

// ============================================================================
// Live progress tracker — three sub-councils, one row per persona, each
// row has 3 step pips that fill as analyse/critique/decide complete.
// ============================================================================
const SUB_COUNCIL_META = {
  analysis:  { label: "ANALYSIS",  color: "var(--cyan)",   tone: "cyan",   note: "what's the trade?" },
  research:  { label: "ANALYSIS",  color: "var(--cyan)",   tone: "cyan",   note: "what's the trade?" },  // alias for legacy backend
  risk:      { label: "RISK",      color: "var(--down)",   tone: "down",   note: "what could break it?" },
  execution: { label: "EXECUTION", color: "var(--amber)",  tone: "amber",  note: "how to enter?" },
};

// Mirrors backend DEFAULT_SUB_COUNCIL — used as fallback before /api/council/models loads.
const DEFAULT_SUB_COUNCIL_FRONT = {
  claude:     "research",
  gpt:        "research",
  deepseek:   "research",
  gemini:     "risk",
  grok:       "risk",
  perplexity: "execution",
};

// Build a placeholder progress map showing the enabled ROLES grouped by
// their assigned sub-council, with status="idle".  Used before Convene so
// the desk layout is visible.  Keyed by role_id (not model_id) — the
// progress tracker uses role_id as the row identity now.
function _buildIdleRoster(enabledSet, roleSubCouncils, roleModels) {
  const out = {};
  for (const id of enabledSet) {
    const role = ROLES.find(r => r.id === id);
    if (!role) continue;
    out[id] = {
      role_id:     id,
      role_name:   role.name,
      role_icon:   role.icon,
      role_color:  role.color,
      role_focus:  role.short_focus,
      model_id:    (roleModels && roleModels[id]) || role.default_model,
      sub_council: (roleSubCouncils && roleSubCouncils[id]) || role.sub_council,
      status: "idle",
      steps: { analyse: null, critique: null, decide: null },
    };
  }
  return out;
}

function CouncilProgress({ progress, plan, chairStatus, isRunning, verdicts,
                           gatherStage, vendorActivity }) {
  // Click a row → expand input/output for that role.  Lookup against
  // `verdicts` for full system+user+response (post-complete), and against
  // `progress.steps` for live previews mid-run.
  const [expandedRow, setExpandedRow] = useState(null);
  const verdictByKey = useMemo(() => {
    const m = new Map();
    (verdicts || []).forEach(v => {
      if (v?.roleId) m.set(v.roleId, v);
      if (v?.model?.id) m.set(v.model.id, v);
    });
    return m;
  }, [verdicts]);
  // Group personas by sub-council.  Normalise legacy "research" → "analysis"
  // so old-backend events land in the same bucket as new-backend events.
  const groups = { analysis: [], risk: [], execution: [] };
  Object.entries(progress).forEach(([mid, p]) => {
    let sc = p.sub_council || "analysis";
    if (sc === "research") sc = "analysis";
    (groups[sc] = groups[sc] || []).push({ mid, ...p });
  });
  const totalPersonas = Object.keys(progress).length;
  const allIdle = totalPersonas > 0 && Object.values(progress).every(p => p.status === "idle");
  const totalSteps = totalPersonas * 3;
  const stepsDone = Object.values(progress).reduce((s, p) => {
    const done = ["analyse", "critique", "decide"].filter(k => p.steps?.[k]).length;
    return s + done;
  }, 0);
  const personasDone = Object.values(progress).filter(p => p.status === "done" || p.status === "error").length;
  const personasRunning = Object.values(progress).filter(p => p.status === "running").length;
  const allChainsDone = totalPersonas > 0 && personasDone === totalPersonas;

  // Live retry countdown — when any vendor is in 429-retry, force a re-render
  // every second so the "retry in Ns" countdown ticks.
  const [, _tick] = useState(0);
  const anyRetrying = vendorActivity && Object.values(vendorActivity).some(v => v && v.status === "rate_limited");
  useEffect(() => {
    if (!anyRetrying) return;
    const t = setInterval(() => _tick(n => n + 1), 1000);
    return () => clearInterval(t);
  }, [anyRetrying]);

  // Pick the most-relevant retry to surface in the phase label (longest wait)
  const retryEntry = useMemo(() => {
    if (!vendorActivity) return null;
    let best = null;
    Object.entries(vendorActivity).forEach(([k, v]) => {
      if (v && v.status === "rate_limited") {
        const remain = Math.max(0, Math.round(((v.retry_until_ts || 0) - Date.now()) / 1000));
        if (!best || remain > best.remain) best = { key: k, ...v, remain };
      }
    });
    return best;
  }, [vendorActivity, /* tick to recompute every second */ anyRetrying ? Date.now() : 0]);

  // Phase resolution — gather stages and rate-limit retries take priority over
  // the generic "queued" / "running" labels so the user sees what the backend
  // is actually doing right now, not a stale aggregate.
  const phase = allIdle && !isRunning ? "idle"
              : retryEntry             ? "rate_limited"
              : (gatherStage && gatherStage.stage && gatherStage.stage !== "done" && !allChainsDone && stepsDone === 0)
                                       ? "gathering"
              : !allChainsDone
                ? (personasRunning > 0 ? "running"
                   : stepsDone > 0      ? "running"
                   : "queued")
                : (chairStatus === "running" ? "chair"
                   : chairStatus === "done"  ? "complete"
                   : chairStatus === "error" ? "chair-error"
                   : "chair-pending");

  // Friendly per-stage gather label
  const _gatherLabel = (gs) => {
    if (!gs) return "";
    const s = gs.stage || "";
    if (s === "start")              return `Connecting to data sources for ${gs.symbol || ""}`;
    if (s === "batch_cache_check")  return gs.hit ? `Indicators in batch cache (${gs.elapsed_ms}ms)` : "Batch cache miss · falling back";
    if (s === "historical_cache")   return `Reading ${gs.rows || 0} candles from ${gs.source || "cache"} (${gs.elapsed_ms}ms)`;
    if (s === "compute_indicators") return `Computing indicators · source=${gs.source} · ${gs.elapsed_ms}ms`;
    if (s === "fetch_ltp")          return `Live LTP fetched (${gs.elapsed_ms}ms)`;
    if (s === "fetch_ltp_error")    return `LTP fetch failed: ${gs.error}`;
    if (s === "indicators_error")   return `Indicators failed: ${gs.error}`;
    if (s === "no_client")          return gs.warning || "Dhan client unavailable";
    return s;
  };

  const phaseLabel = phase === "idle"          ? `READY — ${totalPersonas} agent${totalPersonas !== 1 ? "s" : ""} across 3 sub-councils`
                   : phase === "rate_limited"  ? `RATE-LIMITED · ${retryEntry.vendor} ${retryEntry.http_status} · retry in ${retryEntry.remain}s · attempt ${retryEntry.attempt}/${retryEntry.max_retries}${retryEntry.key && retryEntry.key !== "chair" ? ` · ${retryEntry.key}` : (retryEntry.key === "chair" ? " · chair" : "")}`
                   : phase === "gathering"     ? `GATHERING DATA — ${_gatherLabel(gatherStage)}`
                   : phase === "queued"        ? "QUEUED — backend preparing run"
                   : phase === "running"       ? `STEP ${stepsDone}/${totalSteps} · sub-councils deliberating`
                   : phase === "chair-pending" ? "Sub-councils complete · queueing Chair"
                   : phase === "chair"         ? "STEP 4/4 · Chair synthesising final plan"
                   : phase === "complete"      ? "✓ COMPLETE · trade plan ready"
                   : phase === "chair-error"   ? "✕ Chair failed"
                   : "—";
  const phaseTone = phase === "complete"     ? "up"
                  : phase === "chair-error"  ? "down"
                  : phase === "rate_limited" ? "down"
                  : phase === "idle"         ? "faint"
                  : "amber";
  const overallPct = (totalSteps + 1) > 0 ? ((stepsDone + (chairStatus === "done" ? 1 : chairStatus === "running" ? 0.5 : 0)) / (totalSteps + 1)) * 100 : 0;

  return (
    <div style={{ border: "1px solid var(--border)", background: "var(--bg-0)", padding: 10, marginBottom: 4 }}>
      {/* Overall phase + progress bar */}
      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 4 }}>
        <span className="h-xxs">LIVE PROGRESS</span>
        <span className={phaseTone} style={{ fontSize: 10, fontWeight: 500, letterSpacing: "0.06em" }}>{phaseLabel}</span>
      </div>
      <div style={{ height: 4, background: "var(--bg-3)", overflow: "hidden", marginBottom: 8 }}>
        <div style={{ height: "100%", width: `${overallPct}%`, background: phase === "complete" ? "var(--up)" : "var(--amber)", transition: "width 200ms" }}/>
      </div>

      {/* Sub-council groups */}
      {["analysis", "risk", "execution"].map(sc => {
        const list = groups[sc] || [];
        if (list.length === 0) return null;
        const meta = SUB_COUNCIL_META[sc];
        const subDone   = list.filter(p => p.status === "done").length;
        const subRunning = list.some(p => p.status === "running");
        return (
          <div key={sc} style={{ marginBottom: 6 }}>
            <div style={{ display: "flex", alignItems: "baseline", gap: 6, fontSize: 9, letterSpacing: "0.1em", marginBottom: 3 }}>
              <span style={{ color: meta.color, fontWeight: 700 }}>{meta.label}</span>
              <span className="faint">{meta.note}</span>
              <span style={{ flex: 1 }}/>
              <span className={subDone === list.length ? "up" : subRunning ? "amber" : "faint"} style={{ fontSize: 9 }}>
                {subDone}/{list.length} {subDone === list.length ? "✓" : ""}
              </span>
            </div>
            {list.map(p => {
              const role = p.role_id ? ROLES.find(r => r.id === p.role_id) : null;
              const m = MODELS.find(x => x.id === (p.model_id || p.mid));
              const displayIcon  = role ? role.icon : m?.icon || "·";
              const displayColor = role ? role.color : m?.color || "var(--fg-faint)";
              const displayName  = role ? role.name : m?.name || p.mid;
              const stepNum = ["analyse", "critique", "decide"].filter(k => p.steps?.[k]).length;
              const totalMs  = ["analyse", "critique", "decide"].reduce((s, k) => s + (p.steps?.[k]?.latency_ms || 0), 0);
              const totalSec = totalMs > 0 ? (totalMs / 1000).toFixed(1) : null;
              // Single human-readable status that adapts to phase.  No more
              // 3 pips that all turn green together — just say the thing.
              const fillPct = p.status === "done"    ? 100
                            : p.status === "error"   ? 100
                            : p.status === "running" ? Math.max(8, (stepNum + 0.5) / 3 * 100)
                            : p.status === "decided" ? 100
                            : 0;
              // Vendor-activity overlay — when this role's HTTP call is being
              // retried (429/529) or has just failed, override the generic
              // chain-step status with the raw vendor info.  This is what makes
              // the previously-silent 429 backoff visible to the user.
              const va = vendorActivity ? vendorActivity[p.role_id] : null;
              let statusText, statusTone;
              if (va && va.status === "rate_limited") {
                const remain = Math.max(0, Math.round(((va.retry_until_ts || 0) - Date.now()) / 1000));
                statusText = `${va.vendor} ${va.http_status} · ${remain}s · ${va.attempt}/${va.max_retries}`;
                statusTone = "down";
              } else if (va && va.status === "error") {
                statusText = `✕ ${va.vendor} ${va.http_status || ""} ${(va.body_snippet || va.error || "").slice(0, 30)}`.trim();
                statusTone = "down";
              } else if (va && va.status === "calling") {
                statusText = `${va.step || "calling"}… ${va.vendor}`;
                statusTone = "amber";
              } else {
                statusText = p.status === "idle"    ? "ready"
                           : p.status === "queued"  ? "queued"
                           : p.status === "done"    ? `✓ ${totalSec ? totalSec + "s" : "done"}`
                           : p.status === "error"   ? "✕ " + (p.error || "error").slice(0, 30)
                           : p.current_step         ? `${p.current_step}…  ${stepNum + 1}/3`
                           : `${stepNum}/3`;
                statusTone = p.status === "done" ? "up"
                           : p.status === "running" || p.status === "decided" ? "amber"
                           : p.status === "error" ? "down"
                           : p.status === "idle" ? "cyan"
                           : "faint";
              }
              const rowKey = p.role_id || p.mid;
              const isExpanded = expandedRow === rowKey;
              const verdict = verdictByKey.get(p.role_id) || verdictByKey.get(p.model_id || p.mid);
              const rowFallback = verdict?.usedVendor === "anthropic-fallback";
              return (
                <React.Fragment key={rowKey}>
                <div onClick={() => setExpandedRow(isExpanded ? null : rowKey)}
                     style={{ display: "grid", gridTemplateColumns: "12px 16px 1fr 90px 80px", gap: 10, alignItems: "center", padding: "3px 0", fontSize: 10, cursor: "pointer" }}>
                  <span className="faint" style={{ fontSize: 11, lineHeight: 1 }}>{isExpanded ? "▾" : "▸"}</span>
                  <span style={{ color: displayColor }}>{displayIcon}</span>
                  <div style={{ minWidth: 0 }}>
                    <span className="bright" style={{ fontWeight: 500 }}>{displayName}</span>
                    {m && role ? (
                      <span className="faint" style={{ fontSize: 9, marginLeft: 6 }}
                            title={rowFallback ? `No ${m.vendor} key — ran through Claude with this persona` : undefined}>
                        via {rowFallback ? `Claude (${m.vendor} key missing)` : m.vendor}
                      </span>
                    ) : null}
                  </div>
                  {/* Thin progress bar — fills 0% / 33% / 66% / 100% as chain
                      progresses.  Replaces the 3 separate ANALYSE / CRITIQUE /
                      DECIDE pip badges. */}
                  <div style={{ height: 3, background: "var(--bg-3)", overflow: "hidden", position: "relative" }}>
                    <div style={{
                      position: "absolute", inset: 0, width: `${fillPct}%`,
                      background: p.status === "done" ? "var(--up)"
                                : p.status === "error" ? "var(--down)"
                                : p.status === "running" ? "var(--amber)"
                                : "var(--bg-3)",
                      transition: "width 200ms ease",
                    }}/>
                  </div>
                  <span className={statusTone}
                        style={{ fontSize: 9, textAlign: "right", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
                    {p.status === "running" ? <span className="dot dot-live" style={{ marginRight: 3, width: 4, height: 4 }}/> : null}
                    {statusText}
                  </span>
                </div>
                {isExpanded ? (
                  <ProgressInputOutput rowState={p} verdict={verdict}/>
                ) : null}
                </React.Fragment>
              );
            })}
          </div>
        );
      })}

      {/* Chair status row */}
      <div style={{ borderTop: "1px solid var(--grid)", paddingTop: 6, marginTop: 4, display: "grid", gridTemplateColumns: "16px 130px 1fr 110px", gap: 8, alignItems: "center", fontSize: 10 }}>
        <span className="violet">◆</span>
        <span className="bright" style={{ fontWeight: 500 }}>CHAIR · synthesis</span>
        <span className="faint" style={{ fontSize: 9 }}>combine 3 sub-councils → final plan</span>
        <span className={chairStatus === "running" ? "amber" : chairStatus === "done" ? "up" : chairStatus === "error" ? "down" : "faint"}
              style={{ fontSize: 9, textAlign: "right", whiteSpace: "nowrap" }}>
          {chairStatus === "running" ? <><span className="dot dot-live" style={{ marginRight: 3, width: 4, height: 4 }}/>synthesising…</>
           : chairStatus === "done"  ? "✓ trade plan ready"
           : chairStatus === "error" ? "✕ chair failed"
           : allChainsDone           ? "queued"
                                     : "waiting"}
        </span>
      </div>
    </div>
  );
}

// Cost + cache panel — shown above LIVE PROGRESS.  Updates live as
// chain_step events arrive carrying Anthropic `usage` blocks.  Numbers
// only reflect Anthropic calls (native + fallback path); other vendors
// don't surface usage today, so the panel notes "anthropic-only" if any
// non-Anthropic role ran.
function CostPanel({ usage, isRunning, chairStatus }) {
  const c = computeCostBreakdown(usage);
  const fmt = (n) => "$" + n.toFixed(n < 1 ? 4 : 2).replace(/\.?0+$/, m => m === "." ? "" : m);
  const k = (n) => n >= 1000 ? (n / 1000).toFixed(1) + "k" : String(n);
  const empty = usage.calls === 0;
  return (
    <div style={{
      border: "1px solid var(--border)", background: "var(--bg-0)",
      padding: "8px 10px", marginBottom: 4,
      display: "grid", gridTemplateColumns: "1fr auto", gap: 8, alignItems: "center",
    }}>
      <div>
        <div style={{ display: "flex", alignItems: "baseline", gap: 8, marginBottom: 4 }}>
          <span className="h-xxs">COST · CACHE</span>
          <span className="faint" style={{ fontSize: 9 }}>
            {empty ? "no Anthropic calls yet" : `${usage.calls} call${usage.calls === 1 ? "" : "s"} · sonnet-4-5`}
          </span>
          <span style={{ flex: 1 }}/>
          {!empty && c.cache_hit_pct > 50 ? (
            <span className="up" style={{ fontSize: 9 }}>
              {c.cache_hit_pct.toFixed(0)}% cache hit
            </span>
          ) : null}
        </div>
        <div style={{
          display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 4, fontSize: 10,
        }}>
          <CostCell label="THIS RUN"
                    val={empty ? "—" : fmt(c.total)}
                    sub={empty ? null : `${k(usage.input + usage.cache_read + usage.cache_write)} in · ${k(usage.output)} out`}
                    tone="bright"/>
          <CostCell label="WITHOUT CACHE"
                    val={empty ? "—" : fmt(c.uncached_total)}
                    sub="all input @ full rate"
                    tone="faint"/>
          <CostCell label="SAVED"
                    val={empty ? "—" : fmt(c.saved)}
                    sub={empty ? null : `${(c.saved / Math.max(c.uncached_total, 1e-9) * 100).toFixed(0)}% off`}
                    tone="up"/>
          <CostCell label="CACHE READS"
                    val={empty ? "—" : k(usage.cache_read)}
                    sub={empty ? null : `${k(usage.cache_write)} written`}
                    tone="amber"/>
        </div>
      </div>
      {isRunning || chairStatus === "running" ? (
        <span className="amber" style={{ fontSize: 9, whiteSpace: "nowrap" }}>
          <span className="dot dot-live" style={{ marginRight: 4, width: 4, height: 4 }}/>
          live
        </span>
      ) : null}
    </div>
  );
}

function CostCell({ label, val, sub, tone }) {
  const c = tone === "up" ? "up" : tone === "amber" ? "amber"
          : tone === "faint" ? "faint" : "bright";
  return (
    <div style={{ background: "var(--bg-1)", padding: 5, border: "1px solid var(--grid)" }}>
      <div className="h-xxs" style={{ fontSize: 8 }}>{label}</div>
      <div className={`tnum ${c}`} style={{ fontSize: 13, fontWeight: 500, marginTop: 1 }}>{val}</div>
      {sub ? <div className="faint" style={{ fontSize: 9, marginTop: 1 }}>{sub}</div> : null}
    </div>
  );
}

// Inline expander shown beneath a CouncilProgress row.  Prefers full
// chain data from the matching verdict (post-complete) and falls back to
// the live 300-char preview from streaming `chain_step` events while a
// run is still in flight.
function ProgressInputOutput({ rowState, verdict }) {
  const STEPS = ["analyse", "critique", "decide"];
  const fullChain = Array.isArray(verdict?.chain) ? verdict.chain : null;
  const sysPrompt = verdict?.systemPrompt || "";
  return (
    <div style={{
      gridColumn: "1 / -1", margin: "4px 0 8px 22px",
      border: "1px solid var(--grid)", background: "var(--bg-1)",
    }}>
      {sysPrompt ? (
        <details style={{ borderBottom: "1px solid var(--grid)" }}>
          <summary style={{ padding: "4px 8px", cursor: "pointer", fontSize: 9, display: "flex", justifyContent: "space-between" }}>
            <span className="amber" style={{ letterSpacing: "0.08em" }}>SYSTEM PROMPT</span>
            <span className="faint">{sysPrompt.length} chars</span>
          </summary>
          <pre style={{ padding: "4px 10px 8px", fontSize: 9, lineHeight: 1.5, whiteSpace: "pre-wrap", color: "var(--fg-dim)", margin: 0, maxHeight: 200, overflow: "auto" }}>
            {sysPrompt}
          </pre>
        </details>
      ) : null}
      {STEPS.map((step, i) => {
        const fullEntry = fullChain ? fullChain.find(c => c.step === step) : null;
        const liveStep  = rowState?.steps?.[step];
        const haveAnything = fullEntry || liveStep;
        const userPrompt = fullEntry?.user || "";
        const responseText = fullEntry?.text || liveStep?.preview || "";
        const latency = fullEntry?.latency_ms ?? liveStep?.latency_ms ?? null;
        return (
          <details key={step} style={{ borderTop: i ? "1px solid var(--grid)" : "none" }}
                   open={!fullChain && rowState?.current_step === step}>
            <summary style={{ padding: "4px 8px", cursor: "pointer", fontSize: 9, display: "flex", justifyContent: "space-between" }}>
              <span className="bright" style={{ letterSpacing: "0.08em" }}>{step.toUpperCase()}</span>
              <span className="faint">
                {!haveAnything ? "(waiting)"
                 : latency != null ? `${latency}ms · req ${userPrompt.length}c · resp ${responseText.length}c${liveStep && !fullEntry ? " · preview" : ""}`
                 : ""}
              </span>
            </summary>
            {userPrompt ? (
              <div style={{ borderTop: "1px solid var(--grid)" }}>
                <div className="h-xxs" style={{ padding: "3px 10px", color: "var(--cyan)", fontSize: 8 }}>REQUEST (user prompt)</div>
                <pre style={{ padding: "0 10px 4px", fontSize: 9, lineHeight: 1.5, whiteSpace: "pre-wrap", color: "var(--fg-dim)", margin: 0, maxHeight: 200, overflow: "auto" }}>
                  {userPrompt}
                </pre>
              </div>
            ) : null}
            {responseText ? (
              <div style={{ borderTop: "1px solid var(--grid)" }}>
                <div className="h-xxs" style={{ padding: "3px 10px", color: "var(--up)", fontSize: 8 }}>
                  RESPONSE {liveStep && !fullEntry ? "(300-char preview — full text arrives at run completion)" : ""}
                </div>
                <pre style={{ padding: "0 10px 6px", fontSize: 9, lineHeight: 1.5, whiteSpace: "pre-wrap", color: "var(--fg-dim)", margin: 0, maxHeight: 240, overflow: "auto" }}>
                  {responseText}
                </pre>
              </div>
            ) : (
              !userPrompt ? (
                <div style={{ padding: "4px 10px 6px", fontSize: 9, color: "var(--fg-faint)" }}>
                  Waiting for this step to run…
                </div>
              ) : null
            )}
          </details>
        );
      })}
    </div>
  );
}

function StepPip({ name, status, latency }) {
  const bg = status === "done" ? "var(--up)" : status === "running" ? "var(--amber)" : "var(--bg-3)";
  const fg = status === "done" || status === "running" ? "var(--bg-0)" : "var(--fg-faint)";
  return (
    <span title={status === "done" ? `${name}: ${latency}ms` : `${name}: ${status}`}
      style={{ background: bg, color: fg, padding: "1px 6px", fontSize: 8, fontWeight: 600,
               letterSpacing: "0.05em", borderRadius: 2, textTransform: "uppercase",
               minWidth: 50, textAlign: "center" }}>
      {status === "running" ? <span className="dot dot-live" style={{ marginRight: 3, width: 4, height: 4 }}/> : null}
      {name}
    </span>
  );
}

function SubCouncilGroups({ verdicts, expandedModel, setExpandedModel }) {
  // Normalise legacy "research" → "analysis" so demo + new-backend +
  // legacy-backend events all land in the same bucket.
  const groups = { analysis: [], risk: [], execution: [] };
  verdicts.forEach(v => {
    let sc = v.subCouncil || "analysis";
    if (sc === "research") sc = "analysis";
    (groups[sc] = groups[sc] || []).push(v);
  });
  return (
    <>
      {["analysis", "risk", "execution"].map(sc => {
        const arr = groups[sc] || [];
        if (arr.length === 0) return null;
        const meta = SUB_COUNCIL_META[sc];
        return (
          <div key={sc} style={{ marginTop: 4 }}>
            <div style={{ display: "flex", alignItems: "baseline", gap: 8, marginBottom: 4, padding: "0 2px" }}>
              <span style={{ color: meta.color, fontWeight: 600, fontSize: 10, letterSpacing: "0.1em" }}>{meta.label}</span>
              <span className="faint" style={{ fontSize: 9 }}>{meta.note}</span>
              <span className="faint" style={{ fontSize: 9, marginLeft: "auto" }}>{arr.length} agent{arr.length === 1 ? "" : "s"}</span>
            </div>
            <div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
              {arr.map(v => (
                <VerdictCard key={v.model.id} v={v}
                  expanded={expandedModel === v.model.id}
                  onToggle={() => setExpandedModel(expandedModel === v.model.id ? null : v.model.id)}/>
              ))}
            </div>
          </div>
        );
      })}
    </>
  );
}

// Chair / Council Chair / final-trade-plan card.  Shows the synthesised
// verdict from the Chair LLM call after all sub-councils have spoken.
function ChairCard({ chair, chairStatus, q }) {
  // While the chair is "running", we keep showing the prior chair (now
  // marked stale=true) — replaced in place when the new one lands.  This
  // avoids a blank pane during the ~10-second synthesis call.
  const haveStaleChair = chair && chair.ok && chair.stale;
  if (chairStatus === "idle" && !chair) return null;
  if (chairStatus === "running" && !haveStaleChair) {
    return (
      <Panel title="Council Chair · final synthesis" tag="thinking">
        <div style={{ padding: 12, textAlign: "center", color: "var(--fg-dim)", fontSize: 11 }}>
          <span className="dot dot-live" style={{ marginRight: 6 }}/>
          Synthesising sub-council verdicts into a unified trade plan…
        </div>
      </Panel>
    );
  }
  if (chairStatus === "error" || (chair && !chair.ok && !chair.stale)) {
    return (
      <Panel title="Council Chair · final synthesis">
        <div style={{ padding: 12, color: "var(--down)", fontSize: 11 }}>
          {chair?.error || "Chair synthesis failed"}
        </div>
      </Panel>
    );
  }
  const v = chair.verdict || {};
  const action = String(v.action || "HOLD").toUpperCase();
  const isBuy  = action.includes("BUY") || action === "ACCUMULATE";
  const isSell = action.includes("SELL") || action === "REDUCE";
  return (
    <Panel title="Council Chair · final synthesis"
      tag={chair.stale ? `refreshing` : `${chair.latency_ms}ms`}>
      <div style={{
        filter:  chair.stale ? "grayscale(1) brightness(0.55)" : "none",
        opacity: chair.stale ? 0.5 : 1,
        transition: "filter 200ms ease, opacity 200ms ease",
      }}>
        <div style={{ padding: "8px 0", textAlign: "center", borderBottom: "1px solid var(--border)" }}>
          <div className="h-xxs">FINAL DECISION</div>
          <div style={{ fontSize: 26, fontWeight: 600, letterSpacing: "0.05em", marginTop: 2 }}
               className={isBuy ? "up" : isSell ? "down" : "bright"}>
            {action}
          </div>
          <div className="faint" style={{ fontSize: 10 }}>
            conviction {v.conviction}% · score {(v.score || 0) >= 0 ? "+" : ""}{(v.score || 0).toFixed(2)}
          </div>
        </div>
        <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 4, marginTop: 8 }}>
          <KpiBox label="Target"   val={"₹" + (v.price_target || 0).toFixed(2)} tone="up"/>
          <KpiBox label="Stop"     val={"₹" + (v.stop_loss   || 0).toFixed(2)} tone="down"/>
          <KpiBox label="Size"     val={(v.size_pct_of_book ?? 0) + "%"}/>
          <KpiBox label="Order"    val={v.order_type || "—"}/>
        </div>
        <div style={{ marginTop: 8 }}>
          <div className="h-xxs">ENTRY STRATEGY</div>
          <div style={{ fontSize: 11, color: "var(--fg-bright)", marginTop: 3 }}>{v.entry_strategy || "—"}</div>
        </div>
        <div style={{ marginTop: 8, padding: 8, background: "var(--bg-0)", borderLeft: "2px solid var(--amber)" }}>
          <div className="h-xxs">REASONING</div>
          <div style={{ fontSize: 11, lineHeight: 1.6, marginTop: 3 }}>{v.reasoning || "—"}</div>
        </div>
        {v.sub_council_alignment ? (
          <div style={{ marginTop: 6, fontSize: 10 }}>
            <span className="faint">alignment: </span>
            <span className={v.sub_council_alignment === "aligned" ? "up" : "amber"}>{v.sub_council_alignment}</span>
          </div>
        ) : null}
        {v.key_risks?.length ? (
          <div style={{ marginTop: 8 }}>
            <div className="h-xxs">KEY RISKS</div>
            <ul style={{ margin: 0, paddingLeft: 16, fontSize: 10 }}>
              {v.key_risks.map((k, i) => <li key={i} className="down">{k}</li>)}
            </ul>
          </div>
        ) : null}
        {v.swing_factors?.length ? (
          <div style={{ marginTop: 6 }}>
            <div className="h-xxs">WHAT WOULD CHANGE THIS CALL</div>
            <ul style={{ margin: 0, paddingLeft: 16, fontSize: 10 }}>
              {v.swing_factors.map((k, i) => <li key={i} className="amber">{k}</li>)}
            </ul>
          </div>
        ) : null}
      </div>
    </Panel>
  );
}

// =============================================================================
// BackendActivityPanel — rolling timeline of every gather_*, vendor_*,
// chain_*, and chair_* event the backend has emitted for the current run.
//
// Default-collapsed; click the header to expand a scrollable log.  Always-on
// header chip surfaces the active "what's the backend doing right now" so
// the user gets a one-glance answer even with the body collapsed (e.g.
// "RATE-LIMITED · perplexity 429 · retry in 12s").
// =============================================================================
function BackendActivityPanel({ log, gatherStage, vendorActivity }) {
  const [expanded, setExpanded] = useState(false);

  // Live tick so the retry countdown stays fresh while collapsed too.
  const [, _tick] = useState(0);
  const anyRetrying = vendorActivity && Object.values(vendorActivity).some(v => v && v.status === "rate_limited");
  useEffect(() => {
    if (!anyRetrying) return;
    const t = setInterval(() => _tick(n => n + 1), 1000);
    return () => clearInterval(t);
  }, [anyRetrying]);

  // Pick the most-relevant active retry (longest remaining wait)
  let activeRetry = null;
  if (vendorActivity) {
    Object.entries(vendorActivity).forEach(([k, v]) => {
      if (v && v.status === "rate_limited") {
        const remain = Math.max(0, Math.round(((v.retry_until_ts || 0) - Date.now()) / 1000));
        if (!activeRetry || remain > activeRetry.remain) activeRetry = { key: k, ...v, remain };
      }
    });
  }

  const headerStatus = activeRetry
    ? { tone: "down",
        text: `RATE-LIMITED · ${activeRetry.vendor} ${activeRetry.http_status} · ${activeRetry.remain}s · attempt ${activeRetry.attempt}/${activeRetry.max_retries}` }
    : (gatherStage && gatherStage.stage && gatherStage.stage !== "done")
      ? { tone: "amber",
          text: `GATHERING · ${gatherStage.stage}${gatherStage.elapsed_ms ? ` · ${gatherStage.elapsed_ms}ms` : ""}` }
      : (log && log.length > 0)
        ? { tone: "faint", text: `${log.length} event${log.length === 1 ? "" : "s"}` }
        : { tone: "faint", text: "no events yet" };

  // Format one log entry for display.  Pulls out the most-relevant fields per
  // event type — the rest is dropped because the whole payload would dominate
  // the row.  Click "expand" to see the full JSON if you need it.
  const _fmt = (entry) => {
    const t  = entry.type;
    const p  = entry.payload || {};
    const ts = entry.ts ? new Date(entry.ts).toLocaleTimeString("en-GB", { hour12: false }) + "." + String(entry.ts % 1000).padStart(3, "0") : "";
    let main = t, tone = "faint";
    if (t === "gather_start")          { main = `gather_start · ${p.symbol} (${p.segment})`;            tone = "cyan"; }
    else if (t === "gather_stage")     { main = `gather_stage · ${p.stage}${p.symbol ? " · " + p.symbol : ""}${p.source ? " · " + p.source : ""}${p.hit !== undefined ? " · hit=" + p.hit : ""}${p.rows !== undefined ? " · rows=" + p.rows : ""}${p.elapsed_ms !== undefined ? " · " + p.elapsed_ms + "ms" : ""}${p.error ? " · " + p.error : ""}`; tone = "cyan"; }
    else if (t === "gather_done")      { main = `gather_done · ${p.symbol} · source=${p.source} · ${p.indicator_count} indicators · ltp=${p.ltp} · ${p.total_elapsed_ms}ms`; tone = "cyan"; }
    else if (t === "vendor_call_start"){ main = `vendor_call_start · ${p.vendor} ${p.model} · ${p.role_id || p.role || "?"}/${p.step || "?"} · ${p.prompt_chars}c`; tone = "faint"; }
    else if (t === "vendor_retry")     { main = `vendor_retry · ${p.vendor} ${p.status} · ${p.role_id || p.role || "?"}/${p.step || "?"} · sleep ${p.wait_secs}s · attempt ${p.attempt}/${p.max_retries}${p.retry_after_header ? " · retry-after=" + p.retry_after_header : ""}`; tone = "amber"; }
    else if (t === "vendor_call_done") { main = `vendor_call_done · ${p.vendor} ${p.model} · ${p.role_id || p.role || "?"}/${p.step || "?"} · ${p.latency_ms}ms${p.usage && p.usage.input_tokens ? " · in=" + p.usage.input_tokens + " out=" + p.usage.output_tokens : ""}`; tone = "up"; }
    else if (t === "vendor_call_error"){ main = `vendor_call_error · ${p.vendor} ${p.model || ""} ${p.status || ""} · ${p.role_id || p.role || "?"}/${p.step || "?"} · ${p.error || (p.body_snippet || "").slice(0, 80)}`; tone = "down"; }
    else if (t === "plan")             { main = `plan · ${(p.personas || []).length} personas across ${(p.sub_councils || []).length} sub-councils`; tone = "cyan"; }
    else if (t === "chain_start")      { main = `chain_start · ${p.role_id} (${p.model_id}) · sub=${p.sub_council}`; tone = "amber"; }
    else if (t === "chain_step")       { main = `chain_step · ${p.role_id}/${p.step} · ${p.latency_ms}ms${p.usage && p.usage.cache_read_input_tokens ? " · cache_read=" + p.usage.cache_read_input_tokens : ""}`; tone = "amber"; }
    else if (t === "chain_done")       { main = `chain_done · ${p.role_id} · ${p.ok ? "OK" : "FAIL"}${p.error ? " · " + p.error : ""}${p.used_vendor ? " · via " + p.used_vendor : ""}`; tone = p.ok ? "up" : "down"; }
    else if (t === "chair_start")      { main = "chair_start";                                                                                                       tone = "amber"; }
    else if (t === "chair_done")       { main = `chair_done · ${(p.chair && p.chair.ok) ? "OK" : "FAIL"}${p.chair && p.chair.verdict ? " · " + p.chair.verdict.action + "(" + p.chair.verdict.conviction + "%)" : ""}`; tone = (p.chair && p.chair.ok) ? "up" : "down"; }
    else if (t === "complete")         { main = "complete";                                                                                                          tone = "up"; }
    else if (t === "error")            { main = `error · ${p.error || ""}`;                                                                                          tone = "down"; }
    return { ts, main, tone };
  };

  const visible = expanded ? log.slice().reverse() : [];

  return (
    <div style={{ border: "1px solid var(--border)", background: "var(--bg-1)", marginBottom: 4 }}>
      <div onClick={() => setExpanded(e => !e)}
           style={{ display: "flex", alignItems: "baseline", gap: 8, padding: "5px 10px",
                    cursor: "pointer", background: "var(--bg-2)", borderBottom: expanded ? "1px solid var(--border)" : "none" }}>
        <span className="faint" style={{ fontSize: 11 }}>{expanded ? "▾" : "▸"}</span>
        <span className="h-xxs">BACKEND ACTIVITY</span>
        <span className={headerStatus.tone} style={{ fontSize: 9, fontWeight: 500, letterSpacing: "0.06em" }}>
          {headerStatus.text}
        </span>
        <span style={{ flex: 1 }}/>
        <span className="faint" style={{ fontSize: 9 }}>{(log || []).length} events</span>
      </div>
      {expanded ? (
        <div style={{ maxHeight: 260, overflow: "auto", background: "var(--bg-0)" }}>
          {(visible || []).length === 0 ? (
            <div className="faint" style={{ padding: 10, fontSize: 10 }}>No events yet.</div>
          ) : visible.map((entry, i) => {
            const f = _fmt(entry);
            return (
              <div key={i} style={{ display: "grid", gridTemplateColumns: "92px 1fr",
                                    gap: 8, padding: "2px 10px", fontSize: 10,
                                    borderBottom: "1px solid var(--grid)", fontFamily: "inherit" }}>
                <span className="faint tnum" style={{ fontSize: 9 }}>{f.ts}</span>
                <span className={f.tone} style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }} title={JSON.stringify(entry.payload)}>
                  {f.main}
                </span>
              </div>
            );
          })}
        </div>
      ) : null}
    </div>
  );
}

Object.assign(window, { CouncilPage, ApiKeysPage, CouncilProgress, SubCouncilGroups, ChairCard, StepPip, BackendActivityPanel });
