Ctrl K
ComunidadGuías…

web/js/main.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/* web/js/main.js — application bootstrap and the ask/render flow.
 *
 * SIMULATED mode replays SCENARIO from data.js turn by turn (sample buttons
 * drive the intended story; free-typed text is matched best-effort against
 * the same scripted turns). LIVE mode talks to the real backend per
 * docs/api-contract-v2.md — since /v2/* does not exist on the backend yet
 * (only /query and /health — see api/models.py), it must degrade honestly:
 * a 404 on /v2/sessions is shown as "this backend doesn't support v2 yet",
 * never papered over with a fabricated result.
 */

"use strict";

import {
  SCENARIO, SCENARIO_MATCH_HINTS,
  SCENARIO_SESSIONS, SCENARIO_MEMORY, getSimulatedSessionTurns, FULL_TURNS_BY_ID,
} from "./data.js";
import {
  state, loadPersisted, persistTheme, persistSessionId, persistInterpret,
  applyTheme, addTurn, findTurn, resetTranscript, resolveActiveSessionId,
  resolveBootMode, resolveBootBaseUrl, resolveDefaultBaseUrl, normalizeBaseUrl,
} from "./state.js";
import { Api, V2NotSupportedError, ApiError, UnauthorizedError, RateLimitError } from "./api.js";
import { setApiKey, clearApiKey, hasApiKey } from "./apikey.js";
import {
  CODE_UNEXPECTED_PAYLOAD, createDiagnoser, hideDiagnosisBanner, isApiHealthShape, showDiagnosisBanner,
} from "./diagnosis.js";
import { DEFAULT_BASE_URL, DEFAULT_API_PORT } from "./config.js";
import { createTurnCard, FAILURE_BY_CODE } from "./render/turn.js";
import { canConfirm, renderConfirmationControl } from "./render/confirmation.js";
import { runSimulatedStages } from "./render/pipeline.js";
import { renderSessionList } from "./render/sessions.js";
import { memoryKeyForField, renderMemoryPanel } from "./render/memory.js";
import { ensureTsqlPrismReady } from "./sql-display.js";
import { t, loadLang, setLang, applyLang } from "./i18n.js";

const $ = (id) => document.getElementById(id);

/** Every "bring this into view" call in this module routes through here.
 * style.css already forces `scroll-behavior: auto` under
 * `prefers-reduced-motion: reduce` for CSS-driven scrolling (e.g. anchor
 * jumps), but `Element.scrollIntoView({behavior: "smooth"})` is a
 * script-requested behaviour and, per spec, an explicit "smooth" wins over
 * that CSS property rather than deferring to it — so a hardcoded "smooth"
 * here would silently defeat the reduced-motion rule for the one motion
 * an analyst cannot opt out of by ignoring the page: their own submitted
 * question relocating the viewport out from under them. */
function scrollIntoViewMaybeSmooth(el, opts) {
  const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
  el.scrollIntoView({ ...opts, behavior: reduced ? "auto" : "smooth" });
}

/** D3-hidden-chart: the `{ block: "start" }` scroll that fires the moment a
 * turn card is appended (below) only ever sees the card's *early* content —
 * the result (a chart, table, etc.) is still `hidden` at that point
 * (turn.js's tagLate/revealLate) and gets unhidden afterward, once the
 * pipeline settles. A chart is tall (~440px measured), so that late reveal
 * routinely grows the card past the bottom of the viewport, and nothing
 * re-scrolls to account for it — it just sits there, entirely covered by
 * the sticky composer (measured 0px of a 440px chart visible; see
 * style.css's --composer-clearance comment for the full numbers).
 *
 * Call this once a turn's late content is actually in the DOM (right after
 * `revealLate()`) to close that gap, passing `card.result` (turn.js) —
 * NOT `card.el` (the whole turn card). A first version targeted `card.el`
 * with `block: "end"`: the *card* keeps going well past the result
 * (interpretation, then the collapsed pipeline/LLM/feedback drawer), so
 * aligning the card's bottom with the viewport scrolled the chart itself
 * off the TOP of the screen — visible measurement: the svg ended up at
 * y=-347, above the fold, not above the composer. `card.result` is just
 * the "نتیجه" card, which is short enough that `block: "nearest"` — only
 * scroll if it is not already fully visible — is both correct and the
 * gentler choice: a turn that already fits does not get an extra jump on
 * top of the start-scroll above. `.turn`'s `scroll-margin-bottom:
 * var(--composer-clearance)` (style.css) is what makes "fully visible"
 * mean "visible above the composer" rather than merely on screen — since
 * `card.result` sits inside `.turn`, it inherits that clearance. Falls
 * back to `el` itself (the whole card) for a turn with no result (an
 * error or a guard rejection), where there is nothing else to target. */
function scrollSettledResultAboveComposer(el) {
  if (el) scrollIntoViewMaybeSmooth(el, { block: "nearest" });
}

// Declared before the boot section: setMode(state.mode) runs at module load
// and, in simulated mode, calls setHealth() synchronously — so the label map
// setHealth reads must already be initialized here, not in the module's lower
// half. Declaring it below the boot call put it in the temporal dead zone at
// boot, which threw a ReferenceError and killed the entire simulated UI.
const HEALTH_STATE_LABEL_KEY = { ok: "healthStateUp", down: "healthStateDown", unknown: "healthStateUnknown" };

// Declared here for the same reason: refreshHealth() runs from the boot call
// below. Decides, once per failed status check, whether the address this page
// calls is the API at all (diagnosis.js), so the page can say so in one banner
// instead of three red lights and a tooltip.
const diagnoser = createDiagnoser({
  getBaseUrl: () => state.baseUrl,
  getPageOrigin: () => location.origin,
});
let healthCheckSeq = 0;

/* ── Boot ──────────────────────────────────────────────────────────── */
// T-SQL Prism patch must run before the first Prism.tokenize — Prism
// expands greedy grammar rules in place on first use. Patching after a
// highlight has already run makes the next one throw. See sql-display.js.
ensureTsqlPrismReady();

const params = new URLSearchParams(location.search);
// Deploy-time default (web/js/config.js's DEFAULT_BASE_URL, or — when
// that is left empty — this page's own protocol/host plus
// DEFAULT_API_PORT, see resolveDefaultBaseUrl) first; loadPersisted()
// then overrides it with a localStorage value if one was ever saved
// (e.g. an operator debugging against a different backend from this
// browser) — loadPersisted() itself now repairs/discards a bad saved
// value via normalizeBaseUrl rather than loading it verbatim.
// resolveBootBaseUrl's `?base=` param is the final, highest-precedence
// override, for a one-off load; it is validated below the same way
// before being trusted. Same layering for mode: state.js's own default
// ("live" — see its comment) unless `?live=0`/`?live=1` says otherwise.
// Run mode is URL-only now, decided once at boot; there is no topbar
// control left that can change it afterward.
state.baseUrl = resolveDefaultBaseUrl(DEFAULT_BASE_URL, DEFAULT_API_PORT, location);
loadPersisted();
state.mode = resolveBootMode(params, state.mode);
state.lang = loadLang();
applyTheme();
applyLang(state.lang);

const rawBootBaseUrl = resolveBootBaseUrl(params, state.baseUrl);
if (rawBootBaseUrl !== state.baseUrl) {
  // A `?base=` param was actually supplied for this load -- validate it
  // instead of trusting it silently (the same
  // "172.16.101.42:8076"-style incident this normalisation exists for,
  // just arriving via the URL instead of a typed field). An invalid
  // value is REJECTED, not guessed at: whatever state.baseUrl already
  // resolved to (the persisted value, or the deploy default) stands.
  const result = normalizeBaseUrl(rawBootBaseUrl);
  if (result.ok) {
    state.baseUrl = result.url;
  } else {
    showNotice("error", `${t("invalidBaseUrlNotice")} (?base=${rawBootBaseUrl})`);
  }
}

let api = new Api(state.baseUrl);

// turn_id -> Turn[], keyed by session_id: keeps a conversation's in-tab
// progress when the analyst switches away and back, so flipping between
// sessions in the sidebar never discards what was just asked (see
// switchToSession). Session-local to this tab; never persisted.
const turnsCache = new Map();

// Turn ids the signed-in analyst has confirmed ("پاسخ درست است") in the
// sessions opened so far -- what each turn card's confirmation toggle reads
// its state from, so re-rendering a card (or switching away and back) shows
// the same state. Turn ids are unique, so one set serves every session.
// Declared here, above the boot calls below, for the reason
// HEALTH_STATE_LABEL_KEY is: a const read before its line has run is a
// ReferenceError (see tests/web_ui/test_web_ui_boot_order.py).
const confirmedTurnIds = new Set();

wireComposer();
wireTopbar();
wireSidebar();
wireMemoryPanel();
setMode(state.mode);
tickClock();
setInterval(tickClock, 1000);

/* ── Theme / user menu ─────────────────────────────────────────────── */
function wireTopbar() {
  wireUserMenu();

  $("live-key-save").addEventListener("click", () => {
    const val = $("live-key-input").value;
    if (!val.trim()) return;
    setApiKey(val);
    $("live-key-input").value = "";
    updateKeyStatus();  // leaves the "rejected" state: a new key was given
    showNotice("ok", t("apiKeyStoredNotice"));
    refreshHealth();
  });

  // Per-analyst, persisted, and read fresh on every ask (see askLive).
  const interpretToggle = $("interpret-toggle");
  interpretToggle.checked = state.interpret;
  interpretToggle.addEventListener("change", () => {
    persistInterpret(interpretToggle.checked);
  });

  $("live-key-change").addEventListener("click", () => {
    // Reveals the entry field without touching the stored key, so a
    // mistyped replacement does not leave the analyst with neither.
    $("live-key-entry").hidden = false;
    $("live-key-change").hidden = true;
    $("live-key-input").focus();
  });

  $("live-key-clear").addEventListener("click", () => {
    clearApiKey();
    $("live-key-input").value = "";
    updateKeyStatus();
    showNotice("warn", t("apiKeyClearedNotice"));
  });
  updateKeyStatus();
  updateThemeSegment();
  updateLangSegment();
}

/** ADR-004 part 1: populate the account menu's "درخواست‌های دسترسی"
 * (access requests) status list -- the minimal place an analyst can see
 * their own requests' status and, once denied, the reason. Reuses the
 * account/menu area (theme/language/API key already live there) instead
 * of adding a new panel, and refreshes on every menu open rather than
 * polling, since this list only ever changes as a side effect of the
 * analyst's own actions (submitting a request) or an admin's (approving/
 * denying one) elsewhere.
 *
 * LIVE mode only -- SIMULATED mode has no server-side request store to
 * read (turnCtx().onRequestAccess never actually submits one in that
 * mode either). Every value rendered here (column name, denial reason) is
 * server-supplied and untrusted -- built with `textContent` assignments
 * only, the same rule turn.js's own denied-column rendering follows;
 * never string-built HTML.
 */
async function refreshAccessRequestsList() {
  const host = $("access-requests-list");
  if (!host) return;

  const showEmpty = (message) => {
    host.innerHTML = "";
    const p = document.createElement("p");
    p.className = "access-requests-empty";
    p.textContent = message;
    host.appendChild(p);
  };

  if (state.mode !== "live") {
    showEmpty(t("accessRequestsEmpty"));
    return;
  }

  const STATUS_LABELS = {
    open: t("accessRequestStatusOpen"),
    approved: t("accessRequestStatusApproved"),
    denied: t("accessRequestStatusDenied"),
  };

  try {
    const { access_requests: rows } = await api.listAccessRequests();
    if (!rows || !rows.length) {
      showEmpty(t("accessRequestsEmpty"));
      return;
    }
    host.innerHTML = "";
    for (const row of rows) {
      const item = document.createElement("div");
      item.className = "access-request-item";

      const columnEl = document.createElement("span");
      columnEl.className = "access-request-column";
      columnEl.textContent = row.column_name;
      item.appendChild(columnEl);

      const statusEl = document.createElement("span");
      statusEl.className = `access-request-status access-request-status-${row.status}`;
      statusEl.textContent = STATUS_LABELS[row.status] || row.status;
      item.appendChild(statusEl);

      if (row.status === "denied" && row.resolution_note) {
        const reasonEl = document.createElement("p");
        reasonEl.className = "access-request-reason";
        reasonEl.textContent = row.resolution_note;
        item.appendChild(reasonEl);
      }

      host.appendChild(item);
    }
  } catch (err) {
    // A missing/unsupported endpoint or a network error must not break
    // the rest of the menu -- shown as the same empty-state paragraph,
    // with the real message when there is one.
    showEmpty((err && err.message) || t("accessRequestsEmpty"));
  }
}

/** User menu: theme, language, API key — one popover, not topbar clutter. */
function wireUserMenu() {
  const btn = $("user-menu-btn");
  const panel = $("user-menu-panel");
  if (!btn || !panel) return;

  const close = () => {
    panel.hidden = true;
    btn.setAttribute("aria-expanded", "false");
  };
  const open = () => {
    panel.hidden = false;
    btn.setAttribute("aria-expanded", "true");
    refreshAccessRequestsList();
  };

  btn.addEventListener("click", () => {
    if (panel.hidden) open();
    else close();
  });
  document.addEventListener("click", (e) => {
    if (panel.hidden) return;
    if (!$("user-menu").contains(/** @type {Node} */ (e.target))) close();
  });
  document.addEventListener("keydown", (e) => {
    if (e.key === "Escape" && !panel.hidden) {
      close();
      btn.focus();
    }
  });

  $("theme-segment").querySelectorAll("button[data-theme-val]").forEach((b) => {
    b.addEventListener("click", () => {
      const val = b.getAttribute("data-theme-val");
      if (val === "system" || val === "light" || val === "dark") {
        persistTheme(val);
        applyTheme();
        updateThemeSegment();
      }
    });
  });

  $("lang-segment").querySelectorAll("button[data-lang]").forEach((b) => {
    b.addEventListener("click", () => {
      const lang = b.getAttribute("data-lang");
      if (lang === "fa" || lang === "en") {
        state.lang = lang;
        setLang(lang);
        updateLangSegment();
        updateKeyStatus();
        setMode(state.mode); // refresh foot strings in the new language
      }
    });
  });
}

function updateThemeSegment() {
  document.querySelectorAll("#theme-segment button[data-theme-val]").forEach((b) => {
    b.classList.toggle("active", b.getAttribute("data-theme-val") === state.theme);
  });
}

function updateLangSegment() {
  document.querySelectorAll("#lang-segment button[data-lang]").forEach((b) => {
    b.classList.toggle("active", b.getAttribute("data-lang") === state.lang);
  });
}

/* ── API key state (topbar) ────────────────────────────────────────
 * Three states, because "a key is stored" and "the server accepts it"
 * are different facts and the difference is actionable:
 *
 *   stored    -> pill + "تغییر کلید". No entry field: an empty password
 *                box is what a signed-OUT page shows, and this one is
 *                always empty (it is cleared after a save), so leaving it
 *                on screen told every analyst they were logged out.
 *   unset     -> entry field, no clear/change button (nothing to clear).
 *   rejected  -> entry field AND the stored key kept. The key is printed
 *                exactly once by scripts/issue_api_key.py and is not
 *                recoverable, so a 401 -- which can be transient, e.g. a
 *                server restarted mid-configuration -- must not destroy
 *                the analyst's only copy of it. They can retype it, or
 *                clear it deliberately.
 */
function updateKeyStatus(rejected = false) {
  const el = $("live-key-status");
  const entry = $("live-key-entry");
  const change = $("live-key-change");
  const clear = $("live-key-clear");
  const stored = hasApiKey();

  if (rejected) {
    el.textContent = t("apiKeyRejected");
    el.className = "live-key-status unset";
  } else if (stored) {
    el.textContent = t("apiKeySaved");
    el.className = "live-key-status set";
  } else {
    el.textContent = t("apiKeyUnset");
    el.className = "live-key-status unset";
  }

  const showEntry = rejected || !stored;
  entry.hidden = !showEntry;
  change.hidden = showEntry;
  clear.hidden = !stored;
}

/** Open the user menu and focus the API-key input — used on first live
 * use and after a 401. Never puts the key itself into the input. */
function promptForApiKey(message, { rejected = false } = {}) {
  const panel = $("user-menu-panel");
  const btn = $("user-menu-btn");
  if (panel && btn) {
    panel.hidden = false;
    btn.setAttribute("aria-expanded", "true");
  }
  updateKeyStatus(rejected);
  showNotice("warn", message);
  $("live-key-input").focus();
}

/* ── Mode switch ───────────────────────────────────────────────────── */
function setMode(mode) {
  state.mode = mode;
  updateKeyStatus();

  const foot = $("foot-mode");
  if (mode === "simulated") {
    foot.textContent =
      state.lang === "en"
        ? "Demo mode — data is synthetic; no real query has run."
        : "حالت نمایشی — داده‌ها از پیش تعریف‌شده و کاملاً مصنوعی‌اند؛ هیچ پرس‌وجوی واقعی اجرا نشده است.";
    setHealth(true, true, true, "simulated");
    hideDiagnosisBanner($("api-diagnosis"));
  } else {
    foot.textContent =
      state.lang === "en"
        ? `Live API — backend: ${state.baseUrl}`
        : `حالت زندهٔ API — بک‌اند: ${state.baseUrl}`;
    refreshHealth();
  }

  refreshSessionsForMode();
}

// The dot alone carried the state for nobody but a reader who both sees
// colour and can tell #10b981 apart from #e5484d at a glance. WCAG 1.4.1:
// colour may never be the *only* carrier. Two populations got nothing from
// the old markup — a colour-blind analyst staring at the topbar, and a
// screen-reader user for whom #health's role="status" aria-live="polite"
// read "API LLM DB" on every poll, identically, whatever the state. The
// fix is the state word itself, in two places: as real, visible text next
// to the name (so a colour-blind reader sees the difference, not just a
// hue) and as the pill's aria-label (so the live region announces "API:
// down" instead of re-reading three names that never change). Values from
// /health (h.llmDetail, h.dbDetail, forwarded to setHealth as `label`) are
// never interpolated into markup here — see DESIGN-INVARIANTS.md §1.3.
function setHealth(api_, llm, db, label) {
  const dot = (ok) => (ok === null ? "unknown" : ok ? "ok" : "down");
  const host = $("health");
  host.replaceChildren(
    ...[["API", api_], ["LLM", llm], ["DB", db]].map(([name, ok]) => {
      const state = dot(ok);
      const stateLabel = t(HEALTH_STATE_LABEL_KEY[state]);

      const dotEl = document.createElement("span");
      dotEl.className = `dot dot-${state}`;

      const nameEl = document.createElement("span");
      nameEl.className = "pill-name";
      nameEl.textContent = name;

      const stateEl = document.createElement("span");
      stateEl.className = "pill-state";
      stateEl.textContent = stateLabel;

      const pill = document.createElement("span");
      pill.className = "pill";
      pill.append(dotEl, nameEl, stateEl);
      // aria-label wins over the pill's own text nodes as its accessible
      // name, which is what keeps the live-region announcement a clean
      // "API: down" rather than a run-on of three separately-read spans.
      pill.setAttribute("aria-label", `${name}: ${stateLabel}`);
      return pill;
    }),
  );
  host.title = label;
}

async function refreshHealth() {
  const seq = ++healthCheckSeq;
  try {
    const h = await api.health();
    // A 200 whose body is not the API's health response is some other
    // service answering at this address, not a healthy API: judged here,
    // not trusted as "API up".
    if (!isApiHealthShape(h.raw)) {
      throw new ApiError("/health did not return the API's health response", 200, CODE_UNEXPECTED_PAYLOAD);
    }
    // The tooltip carries the backend's own reason for each light, so a
    // red LLM indicator says WHY rather than leaving the reader to guess
    // between a wrong host, a wrong key, and an endpoint we never call.
    const lines = [`/health: ${h.status}, model ${h.model || "?"}`];
    if (h.llmDetail) lines.push(`LLM: ${h.llmDetail}`);
    if (h.dbDetail) lines.push(`DB: ${h.dbDetail}`);
    setHealth(h.api, h.llm, h.db, lines.join("\n"));
    hideDiagnosisBanner($("api-diagnosis"));
  } catch (err) {
    // TRANSPORT_ERROR (api.js's health()) means the fetch itself never
    // got a response -- unreachable host, or (the incident this branch
    // exists for) a CORS rejection that looks identical to it from the
    // page's point of view. err.message already carries
    // describeTransportFailure's bilingual, actionable hint (naming this
    // page's own origin and CORS_ALLOWED_ORIGINS); a real HTTP error
    // response (e.g. 500) falls through to the plain fallback instead,
    // since that is a live backend, not a transport problem.
    const hint = err && err.code === "TRANSPORT_ERROR" && err.message
      ? err.message
      : "بک‌اند در دسترس نیست — uvicorn api.server:app را اجرا کنید یا حالت نمایشی را انتخاب کنید";
    setHealth(false, false, false, hint);
    // The tooltip above is read by nobody who does not hover the lights; a
    // wrong address or a refused page is said once, in the banner.
    diagnoser.reset();
    const diagnosis = await diagnoser.diagnose(err);
    // A newer check started while this one was probing: its answer wins.
    if (seq === healthCheckSeq) showDiagnosisBanner($("api-diagnosis"), diagnosis, "analyst");
  }
}

/* ── Sessions (sidebar) ────────────────────────────────────────────────
 * The sidebar's own index (`state.sessions`) and the active transcript
 * (`state.turns`) are two separate concerns: the index lists every
 * conversation (title, recency, turn count); the transcript is whichever
 * ONE conversation is currently open. Switching sessions never mutates
 * the index itself, only which transcript is loaded. */

function renderSessionSidebar() {
  const host = $("session-list-host");
  host.innerHTML = "";
  host.appendChild(renderSessionList(state.sessions, {
    selectedId: state.sessionId,
    onSelect: (sessionId) => { if (!state.busy) switchToSession(sessionId); },
    onRename: (sessionId, title) => renameSession(sessionId, title),
    onDelete: (sessionId) => deleteSessionAndFollowUp(sessionId),
  }));
}

/** (Re)resolves and loads the active session for the CURRENT mode.
 * Called on boot and every time the mode switch is clicked — simulated
 * and live modes each have their own, entirely separate, session index. */
async function refreshSessionsForMode() {
  if (state.mode === "live") {
    if (!hasApiKey()) {
      state.sessions = [];
      // Without this, switching from simulated to live-without-a-key left
      // whatever simulated transcript (and its sample-story chips — B5)
      // was already on screen just sitting there under the "live" mode
      // switch, which is exactly the demo-scaffolding-in-production leak
      // this decision exists to close.
      resetTranscript();
      renderTranscriptFromTurns([]);
      renderSessionSidebar();
      return;
    }
    try {
      const res = await api.listSessions();
      state.sessions = res.sessions || [];
    } catch {
      // No v2 support yet, unreachable backend, a blocked cross-origin
      // request, or an expired key — the sidebar just shows no
      // conversations rather than an error banner; askLive's own error
      // handling covers the moment the analyst actually tries to do
      // something.
      //
      // The active session MUST be cleared, not merely left alone. This
      // used to `return` here with state.sessionId untouched, so a failure
      // to list left whatever was active before — and if the analyst had
      // been in simulated mode, that was a scripted id from data.js. The
      // next question then went to the real backend as
      // POST /v2/sessions/s_demo_1404/turns: a demo id, invented locally,
      // sent to a server that has never heard of it.
      //
      // Clearing it means ensureLiveSession() opens a real one on the next
      // question, which is the only correct thing to do when this mode's
      // own index could not be read.
      state.sessions = [];
      state.sessionId = null;
      persistSessionId(null);
      resetTranscript();
      renderTranscriptFromTurns([]);
      renderSessionSidebar();
      return;
    }
  } else {
    state.sessions = SCENARIO_SESSIONS.map((s) => ({ ...s }));
  }

  const resolved = resolveActiveSessionId(state.sessionId, state.sessions);
  if (resolved) {
    await switchToSession(resolved);
  } else {
    state.sessionId = null;
    resetTranscript();
    renderTranscriptFromTurns([]);
    renderSessionSidebar();
  }
}

/** Loads *sessionId*'s transcript into the transcript pane. Stashes the
 * outgoing session's turns in `turnsCache` first (so switching back
 * later restores exactly what was there, instead of refetching and
 * losing anything asked in this tab), then either restores from that
 * cache or loads fresh — from the simulated scenario data, or from the
 * real GET /v2/sessions/{sid} transcript endpoint in live mode. */
async function switchToSession(sessionId) {
  if (sessionId === state.sessionId && state.turns.length > 0) {
    renderSessionSidebar();
    return;
  }
  if (state.sessionId) turnsCache.set(state.sessionId, state.turns);
  persistSessionId(sessionId);

  const cached = turnsCache.get(sessionId);
  if (cached) {
    state.turns = cached;
  } else if (state.mode === "live") {
    try {
      const session = await api.getSession(sessionId);
      state.turns = session.turns || [];
    } catch (err) {
      handleLiveError(err);
      state.turns = [];
    }
    await loadConfirmedTurns(sessionId);
  } else {
    state.turns = getSimulatedSessionTurns(sessionId);
  }
  state.nextScriptedIndex = 0;
  renderTranscriptFromTurns(state.turns);
  renderSessionSidebar();
}

/** Fills confirmedTurnIds from the server for one session. Best effort: a
 * failure leaves the toggles unpressed rather than blocking the transcript
 * (the server stays the source of truth, and pressing a toggle is
 * idempotent). */
async function loadConfirmedTurns(sessionId) {
  try {
    const found = await api.listConfirmations(sessionId);
    for (const id of found.turn_ids || []) confirmedTurnIds.add(id);
  } catch {
    // Not fatal; see above.
  }
}

/** Replaces the whole transcript pane with *turns*, already-resolved (no
 * pipeline animation — these are turns from switching sessions, not a
 * freshly asked question). */
function renderTranscriptFromTurns(turns) {
  const host = $("transcript");
  host.innerHTML = "";
  for (const turn of turns) {
    const card = createTurnCard(turn, turnCtx());
    host.appendChild(card.el);
  }
  if (turns.length === 0) renderTranscriptEmptyState();
  refreshSampleButtonsDisabledState();
}

/** The empty-conversation placeholder — DESIGN.md §5.1's resolution for
 * the sample-story chips that used to sit inside the composer card
 * permanently (overlapping #question's placeholder text) and, per
 * decision B5, have no business appearing in a live production session
 * at all. Rebuilt from scratch on every call rather than toggled with
 * `hidden`, since it must reflect whichever mode is CURRENT, not
 * whichever mode last rendered it. Only called while the active
 * session's transcript is empty — see renderTranscriptFromTurns and
 * appendTurnWithAnimation/askLive's removal of it once a turn lands. */
function renderTranscriptEmptyState() {
  const empty = document.createElement("div");
  empty.className = "transcript-empty";
  empty.id = "transcript-empty";

  const hint = document.createElement("p");
  hint.className = "transcript-empty-hint";
  hint.textContent = t("transcriptEmptyHint");
  empty.appendChild(hint);

  if (state.mode === "simulated") {
    const wrap = document.createElement("div");
    wrap.className = "samples";
    wrap.id = "samples";
    const label = document.createElement("span");
    label.className = "samples-label";
    label.textContent = t("samples");
    wrap.appendChild(label);
    empty.appendChild(wrap);
    renderSamples(wrap);
  } else if (state.mode === "live" && hasApiKey()) {
    // Questions this deployment's examples.yaml curates, offered as
    // one-click starts. Filled in when they arrive; if they never do (an
    // older server, no examples, an error), the hint above is the whole
    // empty state, as before.
    const wrap = document.createElement("div");
    wrap.className = "samples start-examples";
    wrap.id = "start-examples";
    wrap.hidden = true;
    empty.appendChild(wrap);
    renderStartExamples(wrap);
  }

  $("transcript").appendChild(empty);
}

/** The start questions, fetched once per page load (they change only when
 * the operator edits examples.yaml); a failed fetch is not remembered, so
 * the next empty conversation tries again. */
let startExamplesPromise = null;

async function renderStartExamples(wrap) {
  if (!startExamplesPromise) {
    startExamplesPromise = api.listExamples(6).then(
      (body) => (Array.isArray(body && body.examples) ? body.examples : []),
      () => { startExamplesPromise = null; return []; },
    );
  }
  const examples = await startExamplesPromise;
  if (!wrap.isConnected || !examples.length) return;
  const label = document.createElement("span");
  label.className = "samples-label";
  label.textContent = t("startExamples");
  wrap.appendChild(label);
  for (const ex of examples) {
    const question = typeof ex.question === "string" ? ex.question : "";
    if (!question) continue;
    const btn = document.createElement("button");
    btn.className = "sample start-example";
    btn.type = "button";
    btn.textContent = question;
    btn.dir = "auto";
    btn.addEventListener("click", () => {
      if (state.busy) return;
      $("question").value = question;
      ask();
    });
    wrap.appendChild(btn);
  }
  wrap.hidden = false;
}

/** Removes the empty-state placeholder (and, with it, any sample-story
 * chips) the instant a turn is about to land in the transcript — called
 * right before appendTurnWithAnimation/askLive append the new turn/
 * placeholder card, since state.turns itself is not always updated yet
 * at that point (askLive's placeholder predates the "done" event's
 * addTurn call). */
function clearTranscriptEmptyState() {
  document.getElementById("transcript-empty")?.remove();
}

/** A sample button is disabled once its scripted turn has been asked in
 * ANY session this tab has touched (turnsCache + the active session) —
 * these are one-shot "story" prompts, not per-conversation state. */
function refreshSampleButtonsDisabledState() {
  const usedIds = new Set(state.turns.map((t) => t.turn_id));
  for (const cached of turnsCache.values()) for (const t of cached) usedIds.add(t.turn_id);
  document.querySelectorAll(".sample").forEach((btn) => {
    btn.disabled = usedIds.has(btn.dataset.turnId);
  });
}

async function renameSession(sessionId, title) {
  if (state.mode === "live") {
    try {
      const updated = await api.renameSession(sessionId, title);
      const idx = state.sessions.findIndex((s) => s.session_id === sessionId);
      if (idx >= 0) state.sessions[idx] = { ...state.sessions[idx], ...updated };
      renderSessionSidebar();
    } catch (err) {
      handleLiveError(err);
    }
    return;
  }
  const idx = state.sessions.findIndex((s) => s.session_id === sessionId);
  if (idx >= 0) {
    state.sessions[idx] = { ...state.sessions[idx], title };
    renderSessionSidebar();
  }
}

async function deleteSessionAndFollowUp(sessionId) {
  if (state.mode === "live") {
    try {
      await api.deleteSession(sessionId);
    } catch (err) {
      handleLiveError(err);
      return;
    }
  }
  state.sessions = state.sessions.filter((s) => s.session_id !== sessionId);
  turnsCache.delete(sessionId);

  if (state.sessionId !== sessionId) {
    renderSessionSidebar();
    return;
  }
  const next = resolveActiveSessionId(null, state.sessions);
  if (next) {
    await switchToSession(next);
  } else {
    state.sessionId = null;
    persistSessionId(null);
    resetTranscript();
    renderTranscriptFromTurns([]);
    renderSessionSidebar();
  }
}

async function createNewSession() {
  const nowIso = new Date().toISOString();
  if (state.mode === "live") {
    if (!hasApiKey()) {
      promptForApiKey("برای ساخت گفتگوی جدید در حالت زندهٔ API، ابتدا کلید API خود را وارد کنید.");
      return;
    }
    try {
      const session = await api.createSession();
      const sid = session.session_id || session.id;
      state.sessions.unshift({
        session_id: sid, title: "گفتگوی جدید", created_at: nowIso, last_active_at: nowIso,
        turn_count: 0, expires_at: session.expires_at || null,
      });
      turnsCache.set(sid, []);
      await switchToSession(sid);
    } catch (err) {
      handleLiveError(err);
    }
    return;
  }
  const sid = `s_local_${Date.now()}`;
  state.sessions.unshift({
    session_id: sid, title: "گفتگوی جدید", created_at: nowIso, last_active_at: nowIso,
    turn_count: 0, expires_at: null,
  });
  turnsCache.set(sid, []);
  await switchToSession(sid);
}

/** Reflects a just-completed turn back into the sidebar's own summary
 * (turn count, recency) — the index and the transcript would otherwise
 * silently drift apart the moment the very first question is asked. */
function bumpActiveSessionMeta() {
  const idx = state.sessions.findIndex((s) => s.session_id === state.sessionId);
  if (idx < 0) return;
  state.sessions[idx] = {
    ...state.sessions[idx], turn_count: state.turns.length, last_active_at: new Date().toISOString(),
  };
  renderSessionSidebar();
}

function wireSidebar() {
  $("btn-new-session").addEventListener("click", createNewSession);
  $("sidebar-toggle").addEventListener("click", () => {
    const body = document.querySelector(".app-body");
    const open = body.classList.toggle("sidebar-open");
    $("sidebar-toggle").setAttribute("aria-expanded", String(open));
  });
}

/* ── Memory panel ──────────────────────────────────────────────────────
 * Reachable from the topbar, beside the API-key controls. Memory is
 * created EXPLICITLY only — the panel's own "set" controls, or a "📌 به
 * خاطر بسپار" pin on an editable assumption chip (see turnCtx's onPin
 * below) — never inferred by this UI. */

function wireMemoryPanel() {
  $("memory-toggle").addEventListener("click", () => {
    if ($("memory-panel").hidden) openMemoryPanel();
    else closeMemoryPanel();
  });
  $("memory-panel-close").addEventListener("click", closeMemoryPanel);
  $("memory-overlay").addEventListener("click", closeMemoryPanel);
  document.addEventListener("keydown", (e) => {
    if (e.key === "Escape" && !$("memory-panel").hidden) closeMemoryPanel();
  });
}

async function openMemoryPanel() {
  $("memory-panel").hidden = false;
  $("memory-overlay").hidden = false;
  $("memory-toggle").setAttribute("aria-expanded", "true");
  await loadAndRenderMemory();
}

function closeMemoryPanel() {
  $("memory-panel").hidden = true;
  $("memory-overlay").hidden = true;
  $("memory-toggle").setAttribute("aria-expanded", "false");
}

async function loadAndRenderMemory(opts = {}) {
  if (state.mode === "live") {
    if (!hasApiKey()) {
      if (opts.quiet) return;
      promptForApiKey("برای مشاهدهٔ حافظهٔ تحلیلی، ابتدا کلید API خود را وارد کنید.");
      renderMemoryPanelBody({ entries: [], rememberable: [] });
      return;
    }
    try {
      state.memory = await api.getMemory();
    } catch (err) {
      handleLiveError(err);
      return;
    }
  } else if (!state.memory) {
    state.memory = JSON.parse(JSON.stringify(SCENARIO_MEMORY));
  }
  // `quiet` is for the pin path, which needs the rememberable set loaded
  // but must not disturb a panel the analyst has not opened.
  if (!opts.quiet) renderMemoryPanelBody(state.memory);
}

function renderMemoryPanelBody(memory) {
  const host = $("memory-panel-host");
  host.innerHTML = "";
  host.appendChild(renderMemoryPanel(memory, {
    onSetValue: (key, value, errorEl) => setMemoryValue(key, value, errorEl),
    onClearOne: (key) => clearMemoryEntry(key),
    onClearAll: () => clearAllMemory(),
  }));
}

async function setMemoryValue(key, value, errorEl) {
  if (state.mode === "live") {
    try {
      await api.putMemory(key, value);
      state.memory = await api.getMemory();
      renderMemoryPanelBody(state.memory);
    } catch (err) {
      // A 422 (bad value) renders inline, next to the field it came
      // from — not as a page-level notice — since the panel is the only
      // context that makes it actionable. Anything else (401/429/network)
      // gets the usual page-level handling.
      if (err instanceof ApiError && !(err instanceof UnauthorizedError) && !(err instanceof RateLimitError)) {
        errorEl.textContent = err.message;
      } else {
        handleLiveError(err);
      }
    }
    return;
  }
  upsertSimulatedMemory(key, value);
  renderMemoryPanelBody(state.memory);
}

async function clearMemoryEntry(key) {
  if (state.mode === "live") {
    try {
      await api.deleteMemoryEntry(key);
      state.memory = await api.getMemory();
    } catch (err) {
      handleLiveError(err);
      return;
    }
  } else {
    state.memory.entries = state.memory.entries.filter((e) => e.key !== key);
  }
  renderMemoryPanelBody(state.memory);
}

async function clearAllMemory() {
  if (state.mode === "live") {
    try {
      await api.clearMemory();
      state.memory = await api.getMemory();
    } catch (err) {
      handleLiveError(err);
      return;
    }
  } else {
    state.memory.entries = [];
  }
  renderMemoryPanelBody(state.memory);
}

/** Simulated-mode-only: upserts one memory entry locally, matching the
 * field label from `rememberable` when the key is a known one (the panel's
 * own "set" controls always are; a pinned assumption's field usually is
 * too, but falls back to using the field name verbatim if not). */
function upsertSimulatedMemory(key, value) {
  if (!state.memory) state.memory = JSON.parse(JSON.stringify(SCENARIO_MEMORY));
  const knownField = state.memory.rememberable.find((r) => r.key === key);
  const field = (knownField && knownField.field) || key;
  const entry = { key, field, value, updated_at: new Date().toISOString(), applicable: true };
  const idx = state.memory.entries.findIndex((e) => e.key === key);
  if (idx >= 0) state.memory.entries[idx] = entry;
  else state.memory.entries.push(entry);
}

/* ── Composer ──────────────────────────────────────────────────────── */
/** Populates *wrap* (the empty-transcript state's .samples container —
 * see renderTranscriptEmptyState) with one button per scripted turn. */
function renderSamples(wrap) {
  for (const turn of SCENARIO.turns) {
    const btn = document.createElement("button");
    btn.className = "sample";
    btn.type = "button";
    btn.textContent = turn.question;
    btn.dataset.turnId = turn.turn_id;
    btn.dir = "auto";
    btn.addEventListener("click", () => {
      if (state.busy) return;
      $("question").value = turn.question;
      ask();
    });
    wrap.appendChild(btn);
  }
}

function markSampleUsed(turnId) {
  const btn = document.querySelector(`.sample[data-turn-id="${turnId}"]`);
  if (btn) btn.disabled = true;
}

function wireComposer() {
  $("btn-ask").addEventListener("click", ask);
  $("question").addEventListener("keydown", (e) => {
    if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
      e.preventDefault();
      ask();
    }
  });
}

function showNotice(kind, text) {
  const area = $("notice-area");
  const div = document.createElement("div");
  div.className = `notice ${kind}`;
  div.setAttribute("role", kind === "error" ? "alert" : "status");
  div.textContent = text;
  area.innerHTML = "";
  area.appendChild(div);
}

function clearNotice() {
  $("notice-area").innerHTML = "";
}

/* ── Ask flow ──────────────────────────────────────────────────────── */
async function ask() {
  const q = $("question").value.trim();
  if (!q || state.busy) return;
  state.busy = true;
  $("btn-ask").disabled = true;
  clearNotice();

  try {
    if (state.mode === "live") await askLive(q);
    else await askSimulated(q);
  } finally {
    state.busy = false;
    $("btn-ask").disabled = false;
    $("question").value = "";
  }
}

/* ── Simulated ─────────────────────────────────────────────────────── */
function matchScriptedTurn(q) {
  const exact = SCENARIO.turns.find((t) => t.question === q);
  if (exact) return exact;
  const norm = q.trim();
  let best = null, bestScore = 0;
  for (const t of SCENARIO.turns) {
    const hints = SCENARIO_MATCH_HINTS[t.turn_id] || [];
    let score = 0;
    for (const h of hints) if (norm.includes(h)) score++;
    if (score > bestScore) { bestScore = score; best = t; }
  }
  return bestScore > 0 ? best : null;
}

async function askSimulated(q) {
  const scripted = matchScriptedTurn(q);
  if (!scripted) {
    showNotice("info", "این پرسش در نسخهٔ نمایشی از پیش تعریف نشده است — یکی از نمونه‌های «داستان نمونه» را امتحان کنید.");
    return;
  }
  if (findTurn(scripted.turn_id)) {
    showNotice("info", "این پرسش پیش‌تر در همین گفتگو پاسخ داده شده — به پایین اسکرول کنید.");
    scrollToTurn(scripted.turn_id);
    return;
  }
  await appendTurnWithAnimation(scripted);
  markSampleUsed(scripted.turn_id);
}

async function appendTurnWithAnimation(turn) {
  addTurn(turn);
  clearTranscriptEmptyState();
  const card = createTurnCard(turn, turnCtx());
  $("transcript").appendChild(card.el);
  scrollIntoViewMaybeSmooth(card.el, { block: "start" });

  const failAt = turn.error ? "generate" : (turn.guard && turn.guard.verdict === "rejected" ? "validate" : null);
  await runSimulatedStages(card.pipeline.setStage, {
    failAt,
    onStageSettled: (key) => {
      if (key === "understand") card.revealEarly();
    },
  });
  card.revealEarly();
  card.revealLate();
  scrollSettledResultAboveComposer(card.result || card.el);
  bumpActiveSessionMeta();
}

function scrollToTurn(turnId) {
  const el = document.getElementById(`turn-${turnId}`);
  if (el) {
    el.classList.remove("collapsed");
    scrollIntoViewMaybeSmooth(el, { block: "start" });
    el.animate(
      [{ boxShadow: "0 0 0 3px rgba(13,148,136,.5)" }, { boxShadow: "0 0 0 0 rgba(13,148,136,0)" }],
      { duration: 900 },
    );
  }
}

/* ── Assumption editing / clarification (simulated: re-renders the same
 * scripted turn with the edited value patched in locally — there is no
 * real inference engine behind the demo, so this simulates what a
 * PATCH .../assumptions round trip would change in the UI). ─────────── */
function turnCtx() {
  return {
    onJumpToTurn: (turnId) => scrollToTurn(turnId),
    onEditAssumption: (turnId, field, value) => {
      if (state.mode === "live") {
        patchLiveAssumption(turnId, field, value);
        return;
      }
      const t = findTurn(turnId);
      if (!t) return;
      const a = t.ambiguity.assumptions.find((x) => x.field === field);
      if (a) a.value = value;
      rerenderTurn(t);
      showNotice("ok", `مفروضهٔ «${field}» به «${value}» تغییر کرد (شبیه‌سازی محلی — بدون اجرای مجدد واقعی).`);
    },
    onClarify: (turnId, field, option) => {
      if (state.mode === "live") {
        patchLiveAssumption(turnId, field, option);
        return;
      }
      const t = findTurn(turnId);
      if (!t) return;
      const a = t.ambiguity.assumptions.find((x) => x.field === field);
      if (a) { a.value = option; a.source = "question"; }
      rerenderTurn(t);
      showNotice("ok", `پاسخ شما («${option}») به‌عنوان مفروضهٔ صریح ثبت شد (شبیه‌سازی محلی).`);
    },
    // "پین کردن" — the ONLY way memory gets created. Never inferred.
    onPin: (turnId, field, value) => {
      if (state.mode === "live") pinLiveAssumption(field, value);
      else pinSimulatedAssumption(field, value);
    },
    // "دوباره اجرا کن" — the rows_omitted re-run affordance (turn.js's
    // result block). Not a PATCH/re-fetch of this exact turn (the
    // contract has no such endpoint); it re-asks the same question,
    // which is the closest honest equivalent to "run it again".
    onRerun: (turnId) => rerunTurn(turnId),
    // "ویرایش پرسش" — a guard-rejected turn's failure state (turn.js's
    // renderFailureState). Deliberately NOT onRerun: that re-submits the
    // exact same question, which would just be rejected again by the
    // same PolicyRejection. This puts the original question back in the
    // box and stops — an analyst edits it before sending, they don't get
    // it resent for them.
    onRephrase: (turnId) => {
      const t = findTurn(turnId);
      if (!t) return;
      $("question").value = t.question;
      $("question").focus();
      scrollIntoViewMaybeSmooth($("question"), { block: "center" });
    },
    // "پرسش بدون «ستون»" — the targeted action DESIGN-INVARIANTS.md §8's
    // table specifies for a denied-column refusal ("Ask without that
    // column"), offered only when the guard named exactly one column
    // (turn.js reads `turn.guard.subject`). Mirrors onRephrase's own
    // model — puts an edited question back in the box and stops, rather
    // than resubmitting for the analyst — but seeds an explicit exclusion
    // instruction instead of the bare original question: resending the
    // identical question would just be denied again by the same policy.
    onAskWithoutColumn: (turnId, column) => {
      const t = findTurn(turnId);
      if (!t) return;
      $("question").value = `${t.question} (بدون ستون «${column}»)`;
      $("question").focus();
      scrollIntoViewMaybeSmooth($("question"), { block: "center" });
    },
    // "درخواست دسترسی" (ADR-004 part 1) -- the denied-column failure
    // state's second action, beside "پرسش بدون «ستون»"
    // (DESIGN-INVARIANTS.md §8: "Ask without that column · Request
    // access"). LIVE mode really submits it, scoped to the session this
    // transcript is currently showing; the requester is stamped
    // server-side and the column is re-derived server-side from the audit
    // record, so this call sends nothing but the turn's own identifiers.
    // SIMULATED mode has no server to send it to, so it is accepted
    // locally and shown as such -- mirrors onFlag's own simulated-mode
    // behaviour just below.
    onRequestAccess: async (turnId, _column) => {
      if (state.mode === "live") {
        if (!state.sessionId) throw new Error("هیچ گفتگوی زنده‌ای در جریان نیست.");
        return await api.submitAccessRequest(state.sessionId, turnId);
      }
      showNotice(
        "ok",
        "در حالت نمایشی، درخواست‌های دسترسی به سرور واقعی ارسال نمی‌شوند (فقط شبیه‌سازی محلی).",
      );
      return { already_pending: false };
    },
    // "این عدد درست نیست" (admin panel phase 4). LIVE mode really submits
    // it to the backend, against the session this transcript is currently
    // showing; SIMULATED mode has no server to send it to, so it is
    // accepted locally and shown as such rather than pretending to
    // round-trip a demo scenario through a real endpoint.
    onFlag: async (turnId, category, note) => {
      if (state.mode === "live") {
        if (!state.sessionId) throw new Error("هیچ گفتگوی زنده‌ای در جریان نیست.");
        await api.submitFeedback(state.sessionId, turnId, category, note);
        // The server drops this principal's confirmation of the same turn
        // (the later action wins); the local record follows.
        confirmedTurnIds.delete(turnId);
        return;
      }
      showNotice("ok", "در حالت نمایشی، گزارش‌ها به سرور واقعی ارسال نمی‌شوند (فقط شبیه‌سازی محلی).");
    },
    // "پاسخ درست است" -- confirms an answer; the positive twin of onFlag.
    // LIVE mode really submits it; SIMULATED mode has no server, so it is
    // accepted locally and shown as such, like onFlag above.
    renderConfirmation: (turn, { onReplacedFlag }) => {
      if (!canConfirm(turn)) return null;
      const turnId = turn.turn_id;
      return renderConfirmationControl(turn, {
        confirmed: confirmedTurnIds.has(turnId),
        onConfirm: async () => {
          if (state.mode === "live") {
            if (!state.sessionId) throw new Error("هیچ گفتگوی زنده‌ای در جریان نیست.");
            const result = await api.confirmAnswer(state.sessionId, turnId);
            confirmedTurnIds.add(turnId);
            return result;
          }
          confirmedTurnIds.add(turnId);
          showNotice("ok", "در حالت نمایشی، تأییدها به سرور واقعی ارسال نمی‌شوند (فقط شبیه‌سازی محلی).");
          return { removed_flags: 0 };
        },
        onWithdraw: async () => {
          if (state.mode === "live") {
            if (!state.sessionId) throw new Error("هیچ گفتگوی زنده‌ای در جریان نیست.");
            await api.withdrawConfirmation(state.sessionId, turnId);
          }
          confirmedTurnIds.delete(turnId);
        },
        // The server removed this analyst's open flag on the turn (the later
        // action wins); the flag control mirrors that.
        onConfirmed: (result) => {
          const replaced = Boolean(result && result.removed_flags > 0);
          if (replaced) onReplacedFlag();
          return replaced;
        },
      });
    },
  };
}

function rerenderTurn(turn) {
  const old = document.getElementById(`turn-${turn.turn_id}`);
  const card = createTurnCard(turn, turnCtx());
  if (old) {
    old.replaceWith(card.el);
  } else {
    clearTranscriptEmptyState();
    $("transcript").appendChild(card.el);
    // A turn with no card yet lands at the foot of the transcript: in live
    // mode, answering a clarification or editing an assumption re-asks the
    // question and the server returns it as a NEW turn (TurnEngine.ask
    // appends it with its own turn_id). Bring the analyst to its answer,
    // as a streamed answer does once it settles (#219).
    scrollSettledResultAboveComposer(card.result || card.el);
  }
}

function rerunTurn(turnId) {
  const t = findTurn(turnId);
  if (!t) return;
  if (state.mode === "live") {
    $("question").value = t.question;
    ask();
    return;
  }
  rerunSimulatedTurn(turnId);
}

/** Simulated-mode re-run: restores the turn's REAL data (the version it
 * had before rows were stripped for the "reopened conversation" demo —
 * see data.js's getSimulatedSessionTurns/FULL_TURNS_BY_ID) rather than
 * re-running the ask() pipeline, since askSimulated's own dedup check
 * would just report "already answered" for a turn_id already in
 * state.turns — which is exactly this one. */
function rerunSimulatedTurn(turnId) {
  const full = FULL_TURNS_BY_ID[turnId];
  if (!full) {
    showNotice("info", "بازاجرای این نوبت در نسخهٔ نمایشی پشتیبانی نمی‌شود.");
    return;
  }
  const restored = JSON.parse(JSON.stringify(full));
  const idx = state.turns.findIndex((t) => t.turn_id === turnId);
  if (idx >= 0) state.turns[idx] = restored;
  rerenderTurn(restored);
  showNotice("ok", "دادهٔ این نوبت دوباره اجرا و بارگذاری شد.");
}

async function pinLiveAssumption(field, value) {
  if (!hasApiKey()) {
    promptForApiKey("برای به‌خاطر سپردن این مقدار، ابتدا کلید API خود را وارد کنید.");
    return;
  }
  try {
    // The analyst can pin without ever having opened the memory panel, so
    // the rememberable set may not be loaded yet. Fetch it on demand
    // rather than assuming the panel populated it.
    const rem = () => (state.memory && state.memory.rememberable) || [];
    if (!memoryKeyForField(rem(), field)) await loadAndRenderMemory({ quiet: true });
    const key = memoryKeyForField(rem(), field);
    if (key === null) {
      showNotice(
        "warn",
        `«${field}» جزو مواردی نیست که بتوان به‌خاطر سپرد. فهرست موارد قابل ذخیره در پنل حافظه است.`,
      );
      return;
    }
    await api.putMemory(key, value);
    showNotice("ok", `مفروضهٔ «${field}: ${value}» به‌عنوان اولویت ثابت ذخیره شد — از این پس در گفتگوهای بعدی هم اعمال می‌شود.`);
    if (!$("memory-panel").hidden) await loadAndRenderMemory();
  } catch (err) {
    handleLiveError(err);
  }
}

function pinSimulatedAssumption(field, value) {
  upsertSimulatedMemory(field, value);
  showNotice("ok", `مفروضهٔ «${field}: ${value}» به‌عنوان اولویت ثابت ذخیره شد (شبیه‌سازی محلی) — از این پس در گفتگوهای بعدی هم اعمال می‌شود.`);
  if (!$("memory-panel").hidden) renderMemoryPanelBody(state.memory);
}

/* ── Live ──────────────────────────────────────────────────────────── */
async function ensureLiveSession() {
  if (state.sessionId) return state.sessionId;
  const session = await api.createSession();
  const sid = session.session_id || session.id;
  persistSessionId(sid);
  const nowIso = new Date().toISOString();
  state.sessions.unshift({
    session_id: sid, title: "گفتگوی جدید", created_at: nowIso, last_active_at: nowIso,
    turn_count: 0, expires_at: session.expires_at || null,
  });
  renderSessionSidebar();
  return sid;
}

async function askLive(q) {
  // First use (or after a clear/401): prompt before even attempting the
  // network call, rather than let an entirely predictable 401 round-trip
  // happen first. A stale/revoked key is still caught below, reactively,
  // by the UnauthorizedError branch of handleLiveError.
  if (!hasApiKey()) {
    promptForApiKey("برای پرسیدن سؤال در حالت زندهٔ API، ابتدا کلید API خود را وارد کنید (از مدیر سامانه بگیرید).");
    return;
  }

  let sessionId;
  try {
    sessionId = await ensureLiveSession();
  } catch (err) {
    handleLiveError(err);
    return;
  }

  // Build a placeholder card immediately so the pipeline shows "running"
  // while we wait on SSE — filled in from `stage`/`resolved`/etc. events.
  let working = emptyTurn(q, sessionId);
  clearTranscriptEmptyState();
  const card = createTurnCard(working, turnCtx());
  $("transcript").appendChild(card.el);
  scrollIntoViewMaybeSmooth(card.el, { block: "start" });
  card.revealEarly();
  card.revealLate();

  const stageStates = {};
  // Tracks the turn_id of whatever DOM node is CURRENTLY on screen, so the
  // lookup below always targets the right element even across the "done"
  // event's swap from the placeholder id (`live_...`) to the server's real
  // turn_id. Reassigning `working` to `data.turn` (see the "done" case)
  // changes `working.turn_id` *before* this function runs; looking the old
  // node up by the NEW id would silently find nothing (optional chaining
  // swallows it) and leave the stale placeholder element in the DOM forever
  // — including every click handler still closed over the placeholder id,
  // which broke PATCH .../assumptions (it patched a turn_id that never
  // existed server-side).
  let renderedTurnId = working.turn_id;
  function rebuild() {
    const fresh = createTurnCard(working, turnCtx());
    for (const [k, v] of Object.entries(stageStates)) fresh.pipeline.setStage(k, v);
    document.getElementById(`turn-${renderedTurnId}`)?.replaceWith(fresh.el);
    renderedTurnId = working.turn_id;
    fresh.revealEarly();
    fresh.revealLate();
    Object.assign(card, fresh);
  }

  try {
    await api.askTurnStreaming(sessionId, q, (event, data) => {
      switch (event) {
        case "stage":
          stageStates[data.stage] = data.state === "running" ? "running" : data.state === "error" ? "error" : "done";
          card.pipeline.setStage(data.stage, stageStates[data.stage]);
          break;
        case "resolved":
          Object.assign(working, { resolved_question: data.resolved_question, basis: data.basis });
          rebuild();
          break;
        case "assumptions":
          working.ambiguity = data;
          rebuild();
          break;
        case "sql":
          // `sql_display` is the server's layout of `sql` (absent from an
          // older server): shown as it is until `done` brings the same
          // text, instead of the client formatter's different layout.
          Object.assign(working, { sql: data.sql, sql_display: data.sql_display, guard: data.guard });
          rebuild();
          break;
        case "rows":
          working.result = { columns: data.columns, rows: data.rows, row_count: data.row_count, truncated: !!data.truncated };
          rebuild();
          break;
        case "llm":
          working.llm = data;
          rebuild();
          break;
        case "done":
          working = data.turn || working;
          addTurn(working);
          rebuild();
          // rebuild() just Object.assign()'d `fresh` (including its
          // `result`/`el`) onto `card`, so these reflect the just-rendered
          // final turn -- no separate DOM lookup needed.
          scrollSettledResultAboveComposer(card.result || card.el);
          bumpActiveSessionMeta();
          break;
        case "error":
          working.error = data;
          rebuild();
          break;
        default:
          break;
      }
    }, { interpret: state.interpret });
  } catch (err) {
    if (err instanceof V2NotSupportedError) {
      document.getElementById(`turn-${working.turn_id}`)?.remove();
      handleLiveError(err);
      return;
    }
    // 401 (key revoked mid-session) and 429 (throttled on the turns call
    // itself, even though session creation above succeeded) both get the
    // same distinguishable, actionable notice as everywhere else in live
    // mode — not left to render as a generic TRANSPORT_ERROR inside the
    // turn card, which is exactly the "staring at a spinner" failure mode
    // this branch exists to avoid.
    if (err instanceof UnauthorizedError || err instanceof RateLimitError) {
      document.getElementById(`turn-${working.turn_id}`)?.remove();
      handleLiveError(err);
      return;
    }
    // A real error code from the server (`api.js`'s `ApiError.code`, e.g.
    // "MAINTENANCE_MODE" from `require_not_in_maintenance` on this very
    // route, or "INJECTION_ATTEMPT") is preserved here instead of being
    // relabelled — turn.js's `FAILURE_BY_CODE` renders the right Persian
    // sentence for it. Only a genuine pre-response failure (no `code` at
    // all, or `err` is not even an ApiError) falls back to
    // "TRANSPORT_ERROR", which is what it actually was.
    const code = (err instanceof ApiError && err.code) || "TRANSPORT_ERROR";
    working.error = { code, message: err.message };
    rebuild();
  }
}

async function patchLiveAssumption(turnId, field, value) {
  try {
    const updated = await api.patchAssumptions(state.sessionId, turnId, [{ field, value }]);
    // The server re-asks the question and answers with a new turn (a new
    // turn_id, appended to the session); the turn it re-asked keeps its
    // card, so it keeps its record too. A reply under the same turn_id
    // still replaces that turn in place.
    const idx = state.turns.findIndex((t) => t.turn_id === updated.turn_id);
    if (idx >= 0) state.turns[idx] = updated;
    else state.turns.push(updated);
    rerenderTurn(updated);
  } catch (err) {
    handleLiveError(err);
  }
}

function handleLiveError(err) {
  if (err instanceof V2NotSupportedError) {
    showNotice("warn", "بک‌اند نسخهٔ گفتگویی v2 را هنوز پشتیبانی نمی‌کند (404 روی /v2/sessions). حالت به «نمایشی» بازگردانده شد.");
    setMode("simulated");
    return;
  }
  // 401: distinguished from a generic ApiError so a rejected/expired key
  // never just sits there as an unexplained error — the stored key is
  // cleared and the analyst is re-prompted immediately (never shown the
  // key itself, only this message).
  if (err instanceof UnauthorizedError) {
    // Deliberately does NOT clear the stored key. It was printed once by
    // scripts/issue_api_key.py and is unrecoverable, and a 401 is not
    // always the key's fault -- a server restarted with a different
    // API_KEYS_JSON, or an app database briefly unreachable, produces one
    // from a perfectly good key. Destroying it here made a transient
    // server condition into "find that 43-character string again".
    promptForApiKey(
      "سرور کلید ذخیره‌شده را نپذیرفت (۴۰۱). اگر کلید درست است، ممکن است " +
      "سرور تازه ری‌استارت شده باشد؛ دوباره امتحان کنید. در غیر این صورت " +
      "کلید تازه‌ای وارد کنید — کلید فعلی پاک نشده است.",
      { rejected: true },
    );
    return;
  }
  // 429: client-side rate limiting, not a query or model failure — see
  // api/middleware.py's RateLimitMiddleware. Surfaced with its own
  // wording (and the retry-after it carries) instead of the generic
  // "backend error" banner, which is exactly what let this exact
  // response get misread as a query failure once already (see this
  // branch's PR description).
  if (err instanceof RateLimitError) {
    const retry = err.retryAfterSeconds != null
      ? `${Math.max(1, Math.ceil(err.retryAfterSeconds))} ثانیهٔ دیگر`
      : "چند لحظهٔ دیگر";
    showNotice("warn", `محدودیت نرخ درخواست (این خطای محدودسازی سمت کلاینت است، نه خطای پرسش یا مدل) — ${retry} دوباره امتحان کنید. ${err.message}`);
    return;
  }
  if (err instanceof ApiError) {
    // A recognised backend code (MAINTENANCE_MODE, SERVER_OVERLOAD,
    // INJECTION_ATTEMPT, ...) gets the same Persian sentence a turn-card
    // failure banner would show — `render/turn.js`'s `FAILURE_BY_CODE`,
    // the one table for both. "TRANSPORT_ERROR" is deliberately excluded
    // here: outside a turn card this message is `describeTransportFailure`'s
    // own diagnostic (host/CORS guess), not backend text, and it is worth
    // more to an analyst here than the generic sentence would be.
    const entry = err.code && err.code !== "TRANSPORT_ERROR" ? FAILURE_BY_CODE[err.code] : null;
    showNotice("error", entry ? entry.lead : `خطای بک‌اند: ${err.message}`);
    return;
  }
  showNotice("error", `خطای غیرمنتظره: ${err.message}`);
}

function emptyTurn(question, sessionId) {
  return {
    turn_id: `live_${Date.now()}`,
    session_id: sessionId,
    index: state.turns.length + 1,
    question,
    resolved_question: null,
    basis: { kind: "fresh", refines_turn_id: null, composition: "none", inherited: [] },
    sql: null,
    ambiguity: { is_ambiguous: false, assumptions: [], clarifications: [] },
    guard: null,
    result: null,
    interpretation: null,
    tier: null,
    warnings: [],
    llm: null,
    timings: {},
    error: null,
  };
}

/* ── Footer clock ──────────────────────────────────────────────────── */
function tickClock() {
  $("foot-time").textContent = new Date().toLocaleTimeString("fa-IR");
}