/* global React */
// ─── P2.4.1 — THE AGENT PAGE ──────────────────────────────────────────────────
// Phase 2 of the multi-project build. The Agent is a chat that DOES the work. Ask for
// something and it calls the tools straight away, then tells you what it did. It used to
// propose a plan you had to approve; that gate was removed on 16 Sep 2026 (Hugo: "I dont
// want a rule where it cant do things if i ask it to do things"). It is safe without the
// gate because no tool can move, delete, rename or publish: imports copy, drafts go to _wip.
// Every call is still recorded in agent_actions.
//
// The PLAN panel survives for INTAKE only, where the project does not exist yet and the
// manifest genuinely is a proposal the New-project wizard turns into folders.
//
//   window.AgentChat — the reusable chat body (chat column + plan panel). The
//     intake modal mounts this with mode="intake", where a `propose_manifest`
//     action is handed to the modal's own checklist instead of being approved
//     here (the modal owns that decision).
//   window.AgentPage — the page for view === "agent": a chat list + AgentChat.
//
// Invariant #20 (no layout shift) shapes the markup: the plan panel is ALWAYS
// rendered at the same size — header, scrolling list, footer row — whether there
// is a plan or not, the "thinking…" slot keeps its height when idle, and the
// status line under the input is always present (a non-breaking space when
// empty). Nothing below a control moves when the control is used.
// Invariant #22: no window.confirm / alert anywhere — Discard asks inline, in
// the footer's own row, and errors are shown on the status line.

// ── providers ────────────────────────────────────────────────────────────────
// Babel-standalone has no TDZ and no modules: every top-level `const` here is a
// `var` on window, so everything is prefixed to stay out of the shared globals.
// v4 — OpenAI (gpt-6-astra, reasoning "high") is the default; the server picks the
// same when the browser sends nothing. The hint shows in the button tooltip.
// v9 — no Mock button (Hugo: "whats' mock?" — the fake model the tests use; AGENT_PROVIDER=mock
// still works server-side). No hints either: no tooltips anywhere (Hugo).
const _agentProviders = [
  { id: "openai", label: "OpenAI" },
  { id: "gemini", label: "Gemini" },
];   // 23 Sep 2026 — Kimi removed (Hugo); a stored "kimi" choice falls back to OpenAI in _agentReadProvider
// Only these two tools are reversible server-side, so only their rows get Undo.
const _agentUndoable = { update_asset: 1, update_document: 1 };

// v5 — the model answers in Markdown (### heads, **bold**, numbered lists) and
// the bubble showed the signs (Hugo: "formating is not working, i dont like
// seeing all these signs"). src/agentMarkdown.js (plain JS, unit-tested) turns
// an assistant reply into React elements — never innerHTML. User bubbles stay
// plain text: a person may type a literal * or _.
const _agentMdRenderer = (window._agentMarkdown && window._agentMarkdown.make) ? window._agentMarkdown.make(React) : null;
function _agentMdRender(text) {
  if (!_agentMdRenderer) return text;
  try { return _agentMdRenderer.render(text); } catch (_) { return text; }
}

// v6 — photos in the chat (Hugo: "i cant seem to be able to add photos to the chat
// here. this is needed."). Add photos → POST .../attachments (multipart) → the ids
// ride along with the next message. An <img src> cannot carry the JWT, so a photo is
// fetched through authFetch and shown as a blob URL, cached per url for the page's life.
const _AGENT_MAX_PHOTOS = 6;
const _agentImgCache = new Map();
function _AgentImg({ img, className }) {
  const url = img && img.url;
  const [src, setSrc] = React.useState(() => (url && _agentImgCache.get(url)) || null);
  React.useEffect(() => {
    if (!url) { setSrc(""); return; }
    if (_agentImgCache.has(url)) { setSrc(_agentImgCache.get(url)); return; }
    let dead = false;
    const f = window.authFetch || fetch;
    f(url).then((r) => (r.ok ? r.blob() : Promise.reject(new Error("HTTP " + r.status))))
      .then((b) => { const u = URL.createObjectURL(b); _agentImgCache.set(url, u); if (!dead) setSrc(u); })
      .catch(() => { if (!dead) setSrc(""); });
    return () => { dead = true; };
  }, [url]);
  if (src === "") return <span className={className + " is-broken"}>?</span>;
  return (
    <img className={className} src={src || undefined} alt={(img && img.name) || "photo"}
      onClick={() => { if (src) window.open(src, "_blank", "noopener"); }} />
  );
}

function _agentReadProvider() {
  try {
    const v = localStorage.getItem("agent-provider");
    for (let i = 0; i < _agentProviders.length; i++) if (_agentProviders[i].id === v) return v;
  } catch (_) {}
  return "openai";
}
function _agentWriteProvider(v) { try { localStorage.setItem("agent-provider", v); } catch (_) {} }

// ── fetch ────────────────────────────────────────────────────────────────────
// window.authFetch already stamps X-Active-Project, so every call here is scoped
// to the project the user is looking at without this file knowing about it.
// The server routes land alongside this page; until they do a 404 is expected and
// is reported as a plain status line rather than an exception.
function _agentErrText(statusCode, j) {
  if (j && j.error) return String(j.error);
  if (statusCode === 404) return "Agent routes not available yet.";
  if (statusCode === 401 || statusCode === 403) return "You don't have access to the agent.";
  return "Request failed (HTTP " + statusCode + ").";
}
// 16 Sep 2026 — what the agent DID, in one line of plain English, instead of a card full of tool
// names and arguments. Read-only tools are plumbing; the reply is the answer.
const _AGENT_DID = {
  list_folder: "looked through the project folders",
  read_document: "read a document",
  read_file: "read a file",
  search_documents: "searched the documents",
  list_assets: "looked at the assets",
  read_asset: "read an asset",
  list_shots: "looked at the shot list",
  read_settings: "checked the project settings",
};
function _agentDidLine(calls) {
  const names = calls.map(_agentCallName).filter(Boolean);
  const said = [];
  for (const n of names) { const t = _AGENT_DID[n]; if (t && said.indexOf(t) < 0) said.push(t); }
  if (!said.length) return names.length === 1 ? "Checked the project." : "Checked the project (" + names.length + " lookups).";
  const s = said.length === 1 ? said[0] : said.slice(0, -1).join(", ") + " and " + said[said.length - 1];
  return s.charAt(0).toUpperCase() + s.slice(1) + ".";
}

function _agentFetch(url, opts) {
  const f = window.authFetch || fetch;
  return f(url, opts).then(async (r) => {
    let j = null;
    try { j = await r.json(); } catch (_) {}
    if (!r.ok) { const e = new Error(_agentErrText(r.status, j)); e.status = r.status; throw e; }
    return j || {};
  }, () => {
    // Same-origin request that never got a reply: the tracker itself is down or
    // restarting. A different problem from the model failing, so say so.
    throw new Error("The Film Tracker server didn't respond — it may be restarting.");
  });
}

// v8 — word-by-word replies (Hugo: "can we have word stream rather than having to wait
// for the whole answer to be done and then appear in a block?"). The POST asks for
// text/event-stream; the server sends `delta` (words), `exchange` (a read-only tool
// round: assistant + tool rows), `reset` (start the bubble over — the intake retry),
// then `done` (the same payload the JSON answer carries) or `error`. An older server
// answers plain JSON, which is handled the same way.
// 16 Sep 2026 — Hugo: "if i ask the agent a question and leave the page before the
// question is answered, it stops working."
// The server was never the problem: its SSE writer already swallows writes to a closed
// socket, and there is no abort-on-disconnect, so the turn finishes and the rows land in
// the database. The loss was on THIS side. The run lived in component state, so leaving
// the page unmounted the component, its .then handlers updated a dead tree, and the fresh
// component that mounted on the way back had no idea a turn was still going — it showed an
// idle page that never refreshed when the answer finally arrived.
// So an in-flight turn is tracked HERE, outside React, keyed by chat. A page that mounts
// while one is running picks it up: it shows busy, and refreshes the moment it lands.
const _agentRuns = new Map();     // chatId -> { promise, listeners:Set<fn> }
function _agentRunBegin(chatId, promise) {
  const rec = { promise, listeners: new Set() };
  _agentRuns.set(chatId, rec);
  promise.finally(() => {
    if (_agentRuns.get(chatId) === rec) _agentRuns.delete(chatId);
    for (const fn of rec.listeners) { try { fn(); } catch (_) {} }
  });
  return promise;
}
function _agentRunWatch(chatId, onDone) {
  const rec = chatId ? _agentRuns.get(chatId) : null;
  if (!rec) return null;
  rec.listeners.add(onDone);
  return () => rec.listeners.delete(onDone);
}

function _agentStream(url, body, h) {
  const f = window.authFetch || fetch;
  return f(url, {
    method: "POST",
    headers: { "Content-Type": "application/json", "Accept": "text/event-stream" },
    body: JSON.stringify(body),
  }).then(async (r) => {
    const ct = String(r.headers.get("content-type") || "");
    if (!/text\/event-stream/i.test(ct)) {
      let j = null;
      try { j = await r.json(); } catch (_) {}
      if (!r.ok) { const e = new Error(_agentErrText(r.status, j)); e.status = r.status; throw e; }
      return { payload: j || {}, streamed: false };
    }
    const reader = r.body.getReader();
    const dec = new TextDecoder();
    let buf = "", done = null, err = null;
    const frame = (raw) => {
      let event = null;
      const data = [];
      raw.replace(/\r/g, "").split("\n").forEach((line) => {
        if (line.indexOf("event:") === 0) event = line.slice(6).trim();
        else if (line.indexOf("data:") === 0) data.push(line.slice(5).replace(/^ /, ""));
      });
      if (!data.length) return;
      let j = null;
      try { j = JSON.parse(data.join("\n")); } catch (_) { return; }
      if (event === "delta") { if (h.onDelta) h.onDelta(String(j.text || "")); }
      else if (event === "exchange") { if (h.onExchange) h.onExchange(Array.isArray(j.messages) ? j.messages : []); }
      else if (event === "reset") { if (h.onReset) h.onReset(); }
      else if (event === "done") done = j;
      else if (event === "error") { err = new Error(j.error || "The agent failed."); err.status = j.status || 500; }
    };
    for (;;) {
      const step = await reader.read();
      if (step.done) break;
      buf += dec.decode(step.value, { stream: true });
      let i;
      while ((i = buf.indexOf("\n\n")) >= 0) { frame(buf.slice(0, i)); buf = buf.slice(i + 2); }
    }
    if (buf.trim()) frame(buf);
    if (err) throw err;
    if (!done) throw new Error("The Film Tracker server stopped answering mid-reply — it may be restarting.");
    return { payload: done, streamed: true };
  }, () => {
    throw new Error("The Film Tracker server didn't respond — it may be restarting.");
  });
}

// ── one-line summary of a tool call's arguments ──────────────────────────────
// A plan row has to stay one line tall (invariant #20), so nested values collapse
// to a marker and long strings are clipped.
function _agentArgs(args) {
  if (args == null) return "";
  let o = args;
  if (typeof o === "string") {
    const s = o.trim();
    if (s.charAt(0) === "{" || s.charAt(0) === "[") { try { o = JSON.parse(s); } catch (_) { return s.length > 90 ? s.slice(0, 88) + "…" : s; } }
    else return s.length > 90 ? s.slice(0, 88) + "…" : s;
  }
  if (Array.isArray(o)) return o.length + (o.length === 1 ? " item" : " items");
  if (typeof o !== "object") return String(o);
  const keys = Object.keys(o);
  const out = [];
  for (let i = 0; i < keys.length && out.length < 4; i++) {
    const k = keys[i];
    let v = o[k];
    if (v == null) v = "—";
    else if (Array.isArray(v)) v = "[" + v.length + "]";
    else if (typeof v === "object") v = "{…}";
    else v = String(v);
    if (v.length > 40) v = v.slice(0, 38) + "…";
    out.push(k + " " + v);
  }
  if (keys.length > out.length) out.push("+" + (keys.length - out.length) + " more");
  return out.join(" · ");
}

// tool_calls_json arrives as a JSON string on a synced row, or already parsed.
function _agentToolCalls(m) {
  let v = m && m.tool_calls_json;
  if (!v) return [];
  if (typeof v === "string") { try { v = JSON.parse(v); } catch (_) { return []; } }
  if (Array.isArray(v)) return v;
  if (v && Array.isArray(v.calls)) return v.calls;
  return v ? [v] : [];
}
const _agentCallName = (c) => (c && (c.tool || c.name || c.function)) || "tool";
const _agentCallArgs = (c) => (c && (c.args || c.arguments || c.input)) || null;

// ── per-action status glyph ──────────────────────────────────────────────────
function AgentActionIcon({ status }) {
  const s = status || "pending";
  return (
    <span className={"agent-act-ic is-" + s} aria-label={s}>
      {s === "done" ? (
        <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12.5l4.5 4.5L19 7"/></svg>
      ) : s === "failed" ? (
        <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
      ) : s === "running" ? (
        <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round"><path d="M12 3a9 9 0 0 1 9 9"/></svg>
      ) : (
        <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.6"><circle cx="12" cy="12" r="7"/></svg>
      )}
    </span>
  );
}

// ─────────────────────────────────────────────────────────────────────────────
// AgentChat — the reusable body. Chat on the left, plan on the right.
// ─────────────────────────────────────────────────────────────────────────────
function AgentChat(props) {
  const mode = props.mode || "project";
  const chatId = props.chatId;
  const base = props.endpointBase || "/api/agent";
  const onPlan = props.onPlan;
  const onManifest = props.onManifest;

  const [localProvider, setLocalProvider] = React.useState(_agentReadProvider);
  const provider = props.provider || localProvider;
  const pickProvider = (p) => {
    _agentWriteProvider(p);
    setLocalProvider(p);
    if (props.onProviderChange) props.onProviderChange(p);
  };

  const [messages, setMessages] = React.useState([]);
  const [plan, setPlan] = React.useState(null);
  const [results, setResults] = React.useState(null);   // set once a plan is approved
  // 16 Sep 2026 - Hugo: "i was writing in the agent page, went to another page to
  // check something, came back and what I wrote was gone. cant have that."
  // The composer was plain component state, so leaving the page unmounted it and
  // the draft went with it. It is now parked PER CHAT as you type, restored when
  // the page comes back, and cleared only once the message is actually sent.
  // A ref holds the chat id so the setter can stay a stable useCallback.
  const _chatIdRef = React.useRef(props.chatId);
  React.useEffect(() => { _chatIdRef.current = props.chatId; }, [props.chatId]);
  const _draftKey = (id) => "agent-draft:" + (id || "none");
  const [input, _setInputRaw] = React.useState("");
  const setInput = React.useCallback((v) => {
    _setInputRaw((prev) => {
      const next = typeof v === "function" ? v(prev) : v;
      try {
        const k = _draftKey(_chatIdRef.current);
        if (next && String(next).trim()) localStorage.setItem(k, next);
        else localStorage.removeItem(k);
      } catch (_) {}
      return next;
    });
  }, []);
  // Restore whatever was being written for THIS chat, on mount and on a chat switch.
  React.useEffect(() => {
    let saved = "";
    try { saved = localStorage.getItem(_draftKey(props.chatId)) || ""; } catch (_) {}
    _setInputRaw(saved);
  }, [props.chatId]);
  const [busy, setBusy] = React.useState(false);
  const [loading, setLoading] = React.useState(false);
  const [applying, setApplying] = React.useState(false);
  const [confirmDiscard, setConfirmDiscard] = React.useState(false);
  const [undone, setUndone] = React.useState({});       // action id → busy | done | failed
  const [status, setStatus] = React.useState(null);     // { text, err }
  const [pending, setPending] = React.useState([]);     // photos waiting to go with the next message
  const [uploading, setUploading] = React.useState(0);
  const [dragOver, setDragOver] = React.useState(false);
  const [live, setLive] = React.useState(null);         // the reply as it streams in (null = none)
  // 16 Sep 2026 — messages typed while the agent is thinking wait here instead of being refused.
  // Hugo: "seems I cannot type once it's thinking. i want to be able to queue multiple messages."
  // The queue is CLIENT SIDE and drains strictly one at a time: the server does not lock a chat,
  // so two overlapping turns would interleave rows and leave the transcript permanently scrambled.
  const [queued, setQueued] = React.useState([]);
  const queuedRef = React.useRef([]);                   // send() closes over stale state otherwise
  const localIdRef = React.useRef(0);
  const runRef = React.useRef(null);                    // always the freshest send closure
  const listRef = React.useRef(null);
  const nextLocalId = () => "local-" + (++localIdRef.current) + "-" + Date.now();
  const say = (text, err) => setStatus(text ? { text: text, err: !!err } : null);

  // ── load the transcript (and the latest proposed plan) for this chat ──
  React.useEffect(() => {
    setMessages([]); setPlan(null); setResults(null); setStatus(null);
    setConfirmDiscard(false); setUndone({}); setPending([]); setDragOver(false);
    queuedRef.current = []; setQueued([]);   // a queue belongs to the chat it was typed into
    if (!chatId) { setLoading(false); return; }
    let dead = false;
    setLoading(true);
    _agentFetch(base + "/chats/" + encodeURIComponent(chatId) + "/messages")
      .then((j) => {
        if (dead) return;
        setMessages(Array.isArray(j.messages) ? j.messages : []);
        if (j.plan) { setPlan(j.plan); if (onPlan) onPlan(j.plan); }
      })
      .catch((e) => { if (!dead) say(e.message, true); })
      .finally(() => { if (!dead) setLoading(false); });

    // A turn may STILL BE RUNNING for this chat, started before the page was last
    // left. Show it as busy and reload the transcript the moment it lands — without
    // this the answer sits in the database with nothing on screen ever asking for it,
    // which is exactly what "it stops working" looked like.
    const stopWatch = _agentRunWatch(chatId, () => {
      if (dead) return;
      setBusy(false);
      setLive(null);
      _agentFetch(base + "/chats/" + encodeURIComponent(chatId) + "/messages")
        .then((j) => {
          if (dead) return;
          setMessages(Array.isArray(j.messages) ? j.messages : []);
          if (j.plan) { setPlan(j.plan); if (onPlan) onPlan(j.plan); }
        })
        .catch(() => {});
      if (props.onActivity) props.onActivity();
    });
    if (stopWatch) { setBusy(true); setLive(""); }

    return () => { dead = true; if (stopWatch) stopWatch(); };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [chatId, base]);

  // autoscroll to newest
  React.useEffect(() => { const el = listRef.current; if (el) el.scrollTop = el.scrollHeight; }, [messages, busy, loading, live, queued]);

  const addFiles = (fileList) => {
    if (!chatId) return;   // photos may be attached to a queued message while a turn runs
    const files = Array.from(fileList || []).filter((f) => f && /^image\//.test(f.type));
    if (!files.length) { say("Only photos (jpeg, png, webp, gif) can be added.", true); return; }
    const room = _AGENT_MAX_PHOTOS - pending.length;
    if (room <= 0) { say("Six photos per message at most.", true); return; }
    const fd = new FormData();
    files.slice(0, room).forEach((f) => fd.append("files", f, f.name));
    say(null);
    setUploading((n) => n + 1);
    _agentFetch(base + "/chats/" + encodeURIComponent(chatId) + "/attachments", { method: "POST", body: fd })
      .then((j) => { const imgs = Array.isArray(j.images) ? j.images : []; setPending((p) => p.concat(imgs).slice(0, _AGENT_MAX_PHOTOS)); })
      .catch((e) => say(e.message, true))
      .finally(() => setUploading((n) => Math.max(0, n - 1)));
  };
  const onPaste = (e) => {
    const files = e.clipboardData && e.clipboardData.files;
    if (!files || !files.length) return;
    const imgs = Array.from(files).filter((f) => /^image\//.test(f.type));
    if (imgs.length) { e.preventDefault(); addFiles(imgs); }
  };
  const onDrop = (e) => {
    e.preventDefault(); setDragOver(false);
    if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length) addFiles(e.dataTransfer.files);
  };
  const removePending = (id) => setPending((p) => p.filter((x) => x.id !== id));

  // the actual turn. Split out from send() so a queued message can run through the same path.
  const runSend = (text, imgs) => {
    if (!chatId) return;
    say(null);
    setConfirmDiscard(false);
    setMessages((m) => [...m, { id: nextLocalId(), role: "user", content: text, ...(imgs.length ? { images: imgs } : {}) }]);
    setBusy(true);
    setLive("");
    // 16 Sep 2026 - tools now run inside the turn, so the app has to re-read what they changed
    // (a new asset category is a new tab) once the turn ends, not only after an approve.
    let ranTools = false;
    _agentRunBegin(chatId, _agentStream(base + "/chats/" + encodeURIComponent(chatId) + "/messages",
      { text: text, provider: provider, attachment_ids: imgs.map((x) => x.id) },
      {
        onDelta: (t) => setLive((s) => (s == null ? t : s + t)),
        onExchange: (rows) => { if (rows.length) { ranTools = true; setMessages((m) => m.concat(rows)); } setLive(""); },
        onReset: () => setLive(""),
      }))
      .then(({ payload: j, streamed }) => {
        setLive(null);
        if (ranTools && mode !== "intake") {
          try { if (typeof window.__reloadProjects === "function") window.__reloadProjects(); } catch (_) {}
          try { if (typeof window.reloadAppData === "function") window.reloadAppData(); } catch (_) {}
        }
        // streamed: the exchanges already arrived as events, only the reply is new;
        // plain JSON (an older server): the whole turn comes at once
        const add = streamed
          ? (j.message ? [j.message] : [])
          : (Array.isArray(j.messages) && j.messages.length ? j.messages : (j.message ? [j.message] : []));
        if (add.length) setMessages((m) => m.concat(add));
        const p = j.plan || null;
        setResults(null);
        // v9 — a reply without a new plan does not wipe the one on the table (Hugo: "the Plan
        // is not there anymore"): the intake's folder list, or a project plan still proposed.
        setPlan((prev) => p || ((prev && (mode === "intake" || prev.status === "proposed")) ? prev : null));
        if (p && onPlan) onPlan(p);
        // Intake: `propose_manifest` is the intake modal's checklist, not ours to
        // approve — hand the args over and let the modal drive from there.
        if (p && mode === "intake" && onManifest) {
          const acts = Array.isArray(p.actions) ? p.actions : [];
          for (let i = 0; i < acts.length; i++) {
            if (acts[i].tool === "propose_manifest") { onManifest(acts[i].args); break; }
          }
        }
      })
      .catch((e) => { setLive(null); say(e.message, true); })
      .finally(() => {
        setLive(null); setBusy(false);
        // 15 Sep 2026 — the server names an untitled chat after its first message, so tell the
        // page to refresh its list (Hugo: "i need to stay organised").
        if (props.onActivity) props.onActivity();
      });
  };
  // send() is rebuilt every render; the drain effect must call the FRESHEST one or it would
  // post with a stale chatId and a stale provider.
  runRef.current = runSend;

  const send = () => {
    const text = input.trim();
    const imgs = pending;
    if ((!text && !imgs.length) || uploading || !chatId) return;
    // The composer empties either way, so typing never feels blocked.
    setInput("");
    setPending([]);
    if (busy) {
      const item = { id: nextLocalId(), text: text, images: imgs };
      queuedRef.current = queuedRef.current.concat([item]);
      setQueued(queuedRef.current);
      say(null);
      return;
    }
    runSend(text, imgs);
  };

  // Drain one queued message each time the agent goes idle. Strictly one at a time: the server
  // has no per-chat lock, so two overlapping turns would insert their rows by completion time
  // and interleave the transcript for good.
  React.useEffect(() => {
    if (busy || !chatId || !queuedRef.current.length) return;
    const next = queuedRef.current[0];
    queuedRef.current = queuedRef.current.slice(1);
    setQueued(queuedRef.current);
    if (runRef.current) runRef.current(next.text, next.images || []);
  }, [busy, chatId, queued]);

  const onKey = (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); } };

  const approve = () => {
    if (!plan || applying) return;
    setApplying(true); say(null); setConfirmDiscard(false);
    _agentFetch(base + "/plans/" + encodeURIComponent(plan.id) + "/approve", { method: "POST" })
      .then((j) => {
        const rs = Array.isArray(j.results) ? j.results : [];
        setResults(rs);
        setPlan((p) => (p ? { ...p, status: "approved" } : p));
        const bad = rs.filter((r) => r.status === "failed").length;
        if (bad) say(bad + (bad === 1 ? " action failed" : " actions failed") + " — the change list has the reason.", true);
        // New assets/containers/shots the plan just created won't show up on the
        // other pages until the main /api/data + /api/crew + /api/assets fetch
        // re-runs — App.jsx wires that to window.reloadAppData().
        if (typeof window.reloadAppData === "function") window.reloadAppData();
      })
      .catch((e) => say(e.message, true))
      .finally(() => setApplying(false));
  };

  const discard = () => {
    if (!plan || applying) return;
    setApplying(true);
    _agentFetch(base + "/plans/" + encodeURIComponent(plan.id) + "/discard", { method: "POST" })
      .then(() => {
        setPlan(null); setResults(null); setConfirmDiscard(false); say(null);
        if (onPlan) onPlan(null);
      })
      .catch((e) => say(e.message, true))
      .finally(() => setApplying(false));
  };

  const undo = (actionId) => {
    if (!actionId || undone[actionId] === "busy" || undone[actionId] === "done") return;
    setUndone((u) => ({ ...u, [actionId]: "busy" }));
    _agentFetch(base + "/actions/" + encodeURIComponent(actionId) + "/undo", { method: "POST" })
      .then(() => {
        setUndone((u) => ({ ...u, [actionId]: "done" }));
        say(null);
        if (typeof window.reloadAppData === "function") window.reloadAppData();
      })
      .catch((e) => { setUndone((u) => ({ ...u, [actionId]: "failed" })); say(e.message, true); });
  };

  // ── what the plan panel is showing right now ──
  const showResults = !!results;
  const rows = showResults ? results : (plan && Array.isArray(plan.actions) ? plan.actions : []);
  const manifestMode = mode === "intake" && !!plan && (plan.actions || []).some((a) => a.tool === "propose_manifest");
  const doneN = showResults ? results.filter((r) => r.status !== "failed").length : 0;
  const failN = showResults ? results.filter((r) => r.status === "failed").length : 0;
  const pillKind = showResults ? (failN ? "warn" : "done") : plan ? "open" : "idle";
  const pillText = showResults ? (failN ? "Partly applied" : "Applied") : plan ? (plan.status || "proposed") : "Idle";

  return (
    <div className={"agent-chat agent-chat--" + mode}>
      {/* ── chat column ── */}
      <div className={"agent-main" + (dragOver ? " is-over" : "")}
        onDragOver={(e) => { if (e.dataTransfer && Array.from(e.dataTransfer.types || []).indexOf("Files") >= 0) { e.preventDefault(); if (!dragOver) setDragOver(true); } }}
        onDragLeave={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOver(false); }}
        onDrop={onDrop}>
        {dragOver ? <div className="agent-dropveil">Drop the photos</div> : null}
        <div className="agent-msgs" ref={listRef}>
          {loading ? (
            <div className="agent-empty"><div className="agent-empty-title">Loading the conversation…</div></div>
          ) : messages.length === 0 ? (
            <div className="agent-empty">
              <div className="agent-empty-title">{mode === "intake" ? "Describe the project you want to set up." : "Ask the agent to change something."}</div>
              <div className="agent-empty-body">
                {mode === "intake"
                  ? "It asks what the template needs, then proposes the folders for the checklist beside this one."
                  : "It answers in plain words, and when the answer means building something it just does it, then tells you what it did. Nothing can be moved, deleted or published: imports copy, and drafts land in _wip."}
              </div>
            </div>
          ) : messages.map((m, i) => {
            // 16 Sep 2026 — a tool RESULT is never shown. Hugo: "why is it showing me all the tools
            // stuff? Why cant it just speak normally". Folder listings and file dumps are how the
            // agent works, not something to read; the reply says what it found, and the PLAN panel
            // says what it wants to change. Nothing is hidden that a human needs.
            if (m.role === "tool") return null;
            const calls = m.role === "assistant" ? _agentToolCalls(m) : [];
            // 16 Sep 2026 - Hugo: "what is this little bubble icon before every answer
            // from the agent??" It is not an icon, it is an EMPTY BUBBLE: a turn that
            // carried no text, no image and no tool call still rendered its .agent-bub,
            // and an empty bubble is a small rounded box. A turn with nothing in it has
            // nothing to show, so it renders nothing.
            if (!m.content && !(m.images && m.images.length) && !calls.length) return null;
            return (
              <div key={m.id || i} className={"agent-msg agent-msg--" + (m.role === "user" ? "user" : "assistant")}>
                <div className="agent-bub">
                  {m.content ? (m.role === "user"
                    ? <span className="agent-bub-text">{m.content}</span>
                    : <div className="agent-bub-text agent-md">{_agentMdRender(m.content)}</div>) : null}
                  {m.images && m.images.length ? (
                    <div className="agent-bub-imgs">
                      {m.images.map((img) => <_AgentImg key={img.id || img.url} img={img} className="agent-bub-img" />)}
                    </div>
                  ) : null}
                  {/* One quiet line instead of the arguments: enough to see it did something,
                      nothing to read. An empty reply that only ran tools still shows this. */}
                  {calls.length ? (
                    <div className="agent-didline">{_agentDidLine(calls)}</div>
                  ) : null}
                </div>
              </div>
            );
          })}
          {/* Messages typed while the agent was thinking. They sit here in order and are sent
              one at a time as it frees up, so nothing is lost and nothing overlaps. */}
          {queued.map((q) => (
            <div key={q.id} className="agent-msg agent-msg--user agent-msg--queued">
              <div className="agent-bub">
                <div className="agent-bub-text">{q.text}</div>
                <div className="agent-queued-tag">Queued</div>
              </div>
            </div>
          ))}
          {live ? (
            <div className="agent-msg agent-msg--assistant agent-msg--live">
              <div className="agent-bub"><div className="agent-bub-text agent-md">{_agentMdRender(live)}</div></div>
            </div>
          ) : null}
          {/* 23 Sep 2026 — the one thing a project chat still asks before doing: spending credits.
              The side Plan panel stays intake-only (Hugo, 16 Sep: "why do i need to approve something
              i specifically asked?"), so a plan that queues generations shows this card in the chat,
              and only such a plan. It sits in the scrolling list, so nothing below it moves. */}
          {mode !== "intake" && plan && plan.status === "proposed" && (plan.actions || []).some((a) => a.warning) ? (
            <div className="agent-msg agent-msg--assistant">
              <div className="agent-bub agent-spend">
                <div className="agent-spend-title">Waiting for you: this spends credits</div>
                {(plan.actions || []).filter((a) => a.warning).map((a) => (
                  <div key={a.id} className="agent-spend-line">{String(a.warning)}</div>
                ))}
                <div className="agent-spend-foot">
                  {confirmDiscard ? (
                    <React.Fragment>
                      <span className="agent-spend-note">Discard these jobs?</span>
                      <button type="button" className="agent-btn" onClick={() => setConfirmDiscard(false)} disabled={applying}>Keep</button>
                      <button type="button" className="agent-btn" onClick={discard} disabled={applying}>Discard</button>
                    </React.Fragment>
                  ) : (
                    <React.Fragment>
                      <button type="button" className="agent-btn" onClick={() => setConfirmDiscard(true)} disabled={applying}>Discard</button>
                      <button type="button" className="agent-btn agent-btn--go" onClick={approve} disabled={applying}>
                        {applying ? "Queuing…" : "Queue " + (plan.actions || []).filter((a) => a.warning).length + " × generation"}
                      </button>
                    </React.Fragment>
                  )}
                </div>
              </div>
            </div>
          ) : null}
          {mode !== "intake" && showResults && (results || []).some((r) => r.tool === "queue_generations") ? (
            <div className="agent-msg agent-msg--assistant">
              <div className="agent-bub agent-spend">
                <div className="agent-spend-title">{(results || []).some((r) => r.status === "failed") ? "Some jobs did not queue" : "Queued"}</div>
                {(results || []).filter((r) => r.tool === "queue_generations").map((r) => (
                  <div key={r.id} className={"agent-spend-line" + (r.status === "failed" ? " is-err" : "")}>
                    {r.status === "failed" ? String(r.error || "Failed") : _agentArgs(r.result)}
                  </div>
                ))}
              </div>
            </div>
          ) : null}
        </div>

        {/* Always rendered so the list never jumps when a reply starts (inv. #20). */}
        <div className="agent-thinking" aria-live="polite">
          {busy ? (
            <React.Fragment>
              <span className="agent-dots"><span/><span/><span/></span>
              <span className="agent-thinking-t">{live ? "Writing…" : "Thinking…"}</span>
            </React.Fragment>
          ) : null}
        </div>

        <div className="agent-composer">
          <div className="agent-seg" role="group" aria-label="Model">
            {_agentProviders.map((p) => (
              <button key={p.id} type="button" className={"agent-seg-btn" + (provider === p.id ? " is-on" : "")}
                onClick={() => pickProvider(p.id)}>{p.label}</button>
            ))}
          </div>
          {/* v9 — the photos waiting to go with the next message. Nothing is shown until
              there is one (Hugo: "i dont want to see the Add Photos there. i will just paste
              them or drop them in there."). Paste into the box, or drop anywhere on the chat. */}
          {pending.length || uploading ? (
            <div className="agent-attach">
              <div className="agent-attach-list">
                {pending.map((p) => (
                  <span key={p.id} className="agent-attach-thumb">
                    <_AgentImg img={p} className="agent-attach-img" />
                    <button type="button" className="agent-attach-x" onClick={() => removePending(p.id)} aria-label={"Remove " + p.name}>×</button>
                  </span>
                ))}
                {uploading ? <span className="agent-attach-thumb is-loading"><span className="agent-dots"><span/><span/><span/></span></span> : null}
              </div>
            </div>
          ) : null}
          <div className="agent-composer-row">
            <textarea
              className="agent-input"
              rows={3}
              value={input}
              placeholder={chatId ? "Ask the agent… paste or drop photos here  (Enter to send, Shift+Enter for a new line)" : "Pick or start a chat first"}
              onChange={(e) => setInput(e.target.value)}
              onKeyDown={onKey}
              onPaste={onPaste}
              disabled={!chatId}
              aria-label="Message the agent"
            />
            <button type="button" className="agent-send" onClick={send} disabled={!chatId || uploading > 0 || (!input.trim() && !pending.length)} aria-label="Send">
              <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M22 2 11 13M22 2l-7 20-4-9-9-4 20-7z"/></svg>
            </button>
          </div>
          {/* Fixed-height, single-line status — a nbsp keeps the row when there's
              nothing to say, and CSS clamps it to one line so even a long error
              can't grow the row and nudge the composer (inv. #20). No tooltip. */}
          <div className={"agent-statusline" + (status && status.err ? " is-err" : "")} role="status">
            {status ? status.text : " "}
          </div>
        </div>
      </div>

      {/* 16 Sep 2026 — the Plan drawer is INTAKE ONLY now. Hugo: "These plans on the right are
          fucking useless, i cant click on them or read them, so why are they there why do i need
          to approve something i specifically asked?" For the project agent nothing is proposed
          any more, so the panel had nothing to show; the chat says what was done instead, and
          gets the full width. Intake keeps it, because there the manifest genuinely IS a
          proposal that the New-project wizard turns into folders. */}
      {mode === "intake" ? (
      <aside className="agent-plan">
        <div className="agent-plan-head">
          <span className="agent-plan-title">{showResults ? "Changes" : "Plan"}</span>
          <span className={"agent-plan-pill is-" + pillKind}>{pillText}</span>
        </div>
        <div className="agent-plan-list">
          {rows.length === 0 ? (
            <div className="agent-plan-empty">No plan yet. Ask the agent to build something.</div>
          ) : rows.map((a, i) => {
            const u = undone[a.id];
            return (
              <div key={a.id || i} className={"agent-act" + (a.status === "failed" ? " is-failed" : "")}>
                <AgentActionIcon status={a.status}/>
                <div className="agent-act-main">
                  <div className="agent-act-tool">{a.tool}</div>
                  <div className="agent-act-args">{_agentArgs(a.args)}</div>
                  {/* 23 Sep 2026 — credits are money: a plan that queues generations says so before Approve */}
                  {!showResults && a.warning ? <div className="agent-act-err">{String(a.warning)}</div> : null}
                  {showResults && a.status === "failed" && a.error ? <div className="agent-act-err">{String(a.error)}</div> : null}
                  {showResults && a.status !== "failed" && a.result ? <div className="agent-act-res">{_agentArgs(a.result)}</div> : null}
                </div>
                {showResults && _agentUndoable[a.tool] ? (
                  <button type="button" className={"agent-undo" + (u === "done" ? " is-done" : "")} disabled={u === "busy" || u === "done"}
                    onClick={() => undo(a.id)}>
                    {u === "done" ? "Undone" : u === "busy" ? "…" : "Undo"}
                  </button>
                ) : null}
              </div>
            );
          })}
        </div>
        <div className="agent-plan-foot">
          {confirmDiscard ? (
            <React.Fragment>
              <span className="agent-plan-note">Discard this plan?</span>
              <button type="button" className="agent-btn" onClick={() => setConfirmDiscard(false)}>Keep</button>
              <button type="button" className="agent-btn agent-btn--danger" onClick={discard} disabled={applying}>Discard</button>
            </React.Fragment>
          ) : showResults ? (
            <React.Fragment>
              <span className="agent-plan-note">{doneN} done{failN ? " · " + failN + " failed" : ""}</span>
              <button type="button" className="agent-btn" onClick={() => { setResults(null); setPlan(null); if (onPlan) onPlan(null); }}>Clear</button>
            </React.Fragment>
          ) : manifestMode ? (
            <React.Fragment>
              <span className="agent-plan-note">Sent to the checklist.</span>
              <button type="button" className="agent-btn" onClick={() => setConfirmDiscard(true)} disabled={applying}>Discard</button>
            </React.Fragment>
          ) : (
            <React.Fragment>
              <span className="agent-plan-note">{plan ? rows.length + (rows.length === 1 ? " action" : " actions") : "Nothing proposed"}</span>
              <button type="button" className="agent-btn" onClick={() => setConfirmDiscard(true)} disabled={!plan || applying}>Discard</button>
              <button type="button" className="agent-btn agent-btn--go" onClick={approve} disabled={!plan || applying}>{applying ? "Applying…" : "Approve all"}</button>
            </React.Fragment>
          )}
        </div>
      </aside>
      ) : null}
    </div>
  );
}
window.AgentChat = AgentChat;

// ─────────────────────────────────────────────────────────────────────────────
// AgentPage — view === "agent". Chat list + AgentChat for the selected chat.
// ─────────────────────────────────────────────────────────────────────────────
function AgentPage() {
  const [chats, setChats] = React.useState([]);
  const [sel, setSel] = React.useState(null);
  const [loading, setLoading] = React.useState(true);
  const [creating, setCreating] = React.useState(false);
  const [renaming, setRenaming] = React.useState(null);     // id of the chat being renamed
  const [renameText, setRenameText] = React.useState("");
  const renameRef = React.useRef(null);
  const [status, setStatus] = React.useState(null);   // { text, err }
  const [provider, setProvider] = React.useState(_agentReadProvider);
  const readProject = () => window.__activeProjectId || (window.__projects && window.__projects.active) || null;
  const [projectId, setProjectId] = React.useState(readProject);

  // The project switcher doesn't reliably fire an event yet — App just re-points
  // window.__activeProjectId (App.jsx switchProject, ~line 2498). The 800 ms poll
  // is the fallback that always works; a "paradise-project-switch" CustomEvent
  // (detail: { id }), when App dispatches one, updates instantly instead of
  // waiting for the next tick. The chat list is per-project so either path has
  // to drop and refetch it on a switch.
  React.useEffect(() => {
    const t = setInterval(() => {
      const id = readProject();
      setProjectId((p) => (p === id ? p : id));
    }, 800);
    const onSwitch = (e) => {
      const id = (e && e.detail && e.detail.id) || readProject();
      setProjectId((p) => (p === id ? p : id));
    };
    window.addEventListener("paradise-project-switch", onSwitch);
    return () => { clearInterval(t); window.removeEventListener("paradise-project-switch", onSwitch); };
  }, []);

  const load = React.useCallback(() => {
    let dead = false;
    setLoading(true);
    _agentFetch("/api/agent/chats")
      .then((j) => {
        if (dead) return;
        const list = Array.isArray(j.chats) ? j.chats : [];
        setChats(list);
        setSel((s) => (s && list.some((c) => String(c.id) === String(s)) ? s : (list.length ? list[0].id : null)));
        setStatus(null);
      })
      .catch((e) => { if (dead) return; setChats([]); setSel(null); setStatus({ text: e.message, err: true }); })
      .finally(() => { if (!dead) setLoading(false); });
    return () => { dead = true; };
  }, []);
  React.useEffect(() => { setChats([]); setSel(null); return load(); }, [projectId, load]);

  const newChat = () => {
    if (creating) return;
    setCreating(true);
    _agentFetch("/api/agent/chats", {
      method: "POST", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ title: "New chat" }),
    })
      .then((j) => {
        const c = j.chat;
        if (!c) throw new Error("The server didn't return a chat.");
        setChats((list) => [c, ...list]);
        setSel(c.id);
        setStatus(null);
      })
      .catch((e) => setStatus({ text: e.message, err: true }))
      .finally(() => setCreating(false));
  };

  // ── naming a conversation (15 Sep 2026) ──────────────────────────────────────
  // Hugo: "i cant seem to be able to name or rename a chat. i need to stay organised."
  // Click the pencil (or double-click the row) to edit the name in place; Enter or
  // blur saves, Escape cancels. The row updates at once and rolls back if the PATCH
  // fails. A brand-new chat is named by the server from its first message.
  React.useEffect(() => {
    if (renaming && renameRef.current) { renameRef.current.focus(); renameRef.current.select(); }
  }, [renaming]);

  const refreshChats = React.useCallback(() => {
    _agentFetch("/api/agent/chats")
      .then((j) => setChats(Array.isArray(j.chats) ? j.chats : []))
      .catch(() => {});
  }, []);

  const startRename = (c) => { setRenameText(c.title || ""); setRenaming(c.id); };
  const cancelRename = () => setRenaming(null);
  const saveRename = (id) => {
    const cur = chats.find((c) => String(c.id) === String(id));
    const title = String(renameText || "").trim().slice(0, 120);
    setRenaming(null);
    if (!cur || !title || title === String(cur.title || "")) return;
    const before = cur.title;
    setChats((list) => list.map((c) => (String(c.id) === String(id) ? { ...c, title } : c)));
    _agentFetch("/api/agent/chats/" + encodeURIComponent(id), {
      method: "PATCH", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ title }),
    })
      .then((j) => {
        if (j && j.chat) setChats((list) => list.map((c) => (String(c.id) === String(id) ? j.chat : c)));
        setStatus(null);
      })
      .catch((e) => {
        setChats((list) => list.map((c) => (String(c.id) === String(id) ? { ...c, title: before } : c)));
        setStatus({ text: e.message, err: true });
      });
  };

  const projectName = (() => {
    const P = window.__projects;
    const id = projectId || (P && P.active) || "";
    const rows = (P && P.projects) || [];
    for (let i = 0; i < rows.length; i++) if (rows[i].id === id) return rows[i].name || id;
    return id || "this project";
  })();

  const fmtWhen = (s) => {
    if (!s) return "";
    const d = new Date(String(s).replace(" ", "T") + (String(s).indexOf("Z") < 0 && String(s).indexOf("+") < 0 ? "Z" : ""));
    return isNaN(d) ? "" : d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
  };

  return (
    <div className="view-page agent-view">
      <div className="agent-head">
        <div className="agent-head-l">
          <h1 className="agent-title">Agent · {projectName}</h1>
          <p className="agent-sub">Ask for what you want. It does the work and tells you what it did.</p>
        </div>
        <button type="button" className="agent-btn agent-btn--go agent-newchat" onClick={newChat} disabled={creating}>
          {creating ? "Starting…" : "+ New chat"}
        </button>
      </div>

      <div className="agent-body">
        <aside className="agent-chatlist">
          <div className="agent-chatlist-head">Chats</div>
          <div className="agent-chatlist-scroll">
            {loading ? (
              <div className="agent-chatlist-empty">Loading…</div>
            ) : chats.length === 0 ? (
              <div className="agent-chatlist-empty">No chats yet. Start one with “+ New chat”.</div>
                        ) : chats.map((c) => (
              <div key={c.id} className="agent-chatrow-wrap">
                {String(renaming) === String(c.id) ? (
                  <div className={"agent-chatrow" + (String(c.id) === String(sel) ? " is-on" : "")}>
                    <input
                      ref={renameRef}
                      className="agent-rename-input"
                      value={renameText}
                      maxLength={120}
                      aria-label="Chat name"
                      onChange={(e) => setRenameText(e.target.value)}
                      onKeyDown={(e) => {
                        if (e.key === "Enter") { e.preventDefault(); saveRename(c.id); }
                        else if (e.key === "Escape") { e.preventDefault(); cancelRename(); }
                      }}
                      onBlur={() => saveRename(c.id)}
                    />
                    <span className="agent-chatrow-m">
                      {c.kind === "intake" ? <span className="agent-chatrow-kind">intake</span> : null}
                      <span className="agent-chatrow-when">{fmtWhen(c.updated_at || c.created_at)}</span>
                    </span>
                  </div>
                ) : (
                  <React.Fragment>
                    <button
                      type="button"
                      className={"agent-chatrow" + (String(c.id) === String(sel) ? " is-on" : "")}
                      onClick={() => setSel(c.id)}
                      onDoubleClick={() => startRename(c)}
                      aria-current={String(c.id) === String(sel) ? "true" : undefined}
                    >
                      <span className="agent-chatrow-t">{c.title || "Untitled chat"}</span>
                      <span className="agent-chatrow-m">
                        {c.kind === "intake" ? <span className="agent-chatrow-kind">intake</span> : null}
                        <span className="agent-chatrow-when">{fmtWhen(c.updated_at || c.created_at)}</span>
                      </span>
                    </button>
                    <button
                      type="button"
                      className="agent-chatrow-edit"
                      aria-label={"Rename " + (c.title || "chat")}
                      onClick={() => startRename(c)}
                    >
                      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
                        <path d="M12 20h9"/>
                        <path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>
                      </svg>
                    </button>
                  </React.Fragment>
                )}
              </div>
            ))}
          </div>
          {/* Fixed-height status row — the 404 before the server routes land shows here. */}
          <div className={"agent-chatlist-status" + (status && status.err ? " is-err" : "")}>{status ? status.text : " "}</div>
        </aside>

        <AgentChat
          mode="project"
          chatId={sel}
          endpointBase="/api/agent"
          provider={provider}
          onProviderChange={setProvider}
          onActivity={refreshChats}
        />
      </div>
    </div>
  );
}
window.AgentPage = AgentPage;
