/* global React */
// ─── v717 — CHAT IMAGES SURVIVE A RELOAD ──────────────────────────────────────
// Hugo: "why do images not stay in the chat when i reload the page? I can only see
// text". Because they were deliberately thrown away, and the reason was sound but
// the solution was the wrong storage.
//
// Attached images are data URLs, and base64 inflates by ~33%: ONE of this project's
// 21:9 frames is ~1 MB as a data URL. localStorage here holds ~15 MB total, so about
// 14 frames would fill it — and when setItem throws QuotaExceededError the whole
// write fails, taking the TEXT history with it. Faced with "lose the images or lose
// everything", v07zz290 stripped the images. Right call, wrong storage.
//
// IndexedDB is the correct home: it stores Blobs natively (no base64 tax) and the
// quota here measures ~12 GB rather than ~15 MB. So: text stays in localStorage,
// image bytes move to IDB keyed by message, and the two are reunited on load.
const AIC_DB = "filmtracker-assistant";
const AIC_STORE = "chat-images";
const AIC_MAX_IMAGES = 120;          // hard cap; oldest evicted first

function _aicOpen() {
  return new Promise((res, rej) => {
    if (typeof indexedDB === "undefined") return rej(new Error("no indexedDB"));
    const r = indexedDB.open(AIC_DB, 1);
    r.onupgradeneeded = () => { const d = r.result; if (!d.objectStoreNames.contains(AIC_STORE)) d.createObjectStore(AIC_STORE); };
    r.onsuccess = () => res(r.result);
    r.onerror = () => rej(r.error);
  });
}
function _aicTx(mode, fn) {
  return _aicOpen().then(db => new Promise((res, rej) => {
    const tx = db.transaction(AIC_STORE, mode);
    const store = tx.objectStore(AIC_STORE);
    let out;
    try { out = fn(store); } catch (e) { rej(e); return; }
    tx.oncomplete = () => res(out && out.__req ? out.__req.result : out);
    tx.onerror = () => rej(tx.error);
    tx.onabort = () => rej(tx.error);
  }));
}
const _aicPut  = (key, blob) => _aicTx("readwrite", s => { s.put(blob, key); });
const _aicDel  = (keys) => _aicTx("readwrite", s => { keys.forEach(k => s.delete(k)); });
const _aicKeys = () => _aicTx("readonly", s => ({ __req: s.getAllKeys() }));
const _aicGet  = (key) => _aicTx("readonly", s => ({ __req: s.get(key) }));

// data URL ⇄ Blob. Stored as a Blob so the bytes aren't base64-inflated on disk;
// read back as a data URL because that is what the /api/assistant/chat body needs,
// so a restored image is still re-sendable by Retry.
function _aicToBlob(dataUrl) {
  return fetch(dataUrl).then(r => r.blob());
}
function _aicToDataUrl(blob) {
  return new Promise((res, rej) => {
    const fr = new FileReader();
    fr.onload = () => res(fr.result);
    fr.onerror = () => rej(fr.error);
    fr.readAsDataURL(blob);
  });
}
const _aicMsgId = () => "m" + Date.now().toString(36) + Math.random().toString(36).slice(2, 7);

// v07zz241 — Floating AI assistant. A draggable messenger bubble that opens a
// chat to Gemini or ChatGPT, pre-loaded server-side with this project's briefing
// (story era, characters, locations, narrative) so it can answer production +
// historical-research questions in context. Replies are always natural prose —
// the "no bullet lists" rule is enforced in the server's system prompt.
function AssistantChat() {
  const LS = {
    pos: "filmtracker.assistant.pos",
    msgs: "filmtracker.assistant.msgs",
    provider: "filmtracker.assistant.provider",
  };
  const readLS = (k, fb) => { try { const v = localStorage.getItem(k); return v == null ? fb : JSON.parse(v); } catch (_) { return fb; } };

  const [open, setOpen] = React.useState(false);
  const [provider, setProvider] = React.useState(() => { const p = readLS(LS.provider, "gemini"); return p === "gpt" ? p : "gemini"; });   // 23 Sep 2026 — Kimi removed: a saved "kimi" opens Gemini
  // v07zz242 — Gemini and ChatGPT each get their OWN conversation thread, so
  // switching providers opens that model's chat instead of carrying the other's.
  // v07zz622 — Kimi K3 (Moonshot) joins as a third provider with its own thread.
  const [threads, setThreads] = React.useState(() => {
    const t = readLS(LS.msgs, null);
    if (t && !Array.isArray(t) && typeof t === "object") return { gemini: Array.isArray(t.gemini) ? t.gemini : [], gpt: Array.isArray(t.gpt) ? t.gpt : [], kimi: Array.isArray(t.kimi) ? t.kimi : [] };
    if (Array.isArray(t)) return { gemini: t, gpt: [], kimi: [] };   // migrate the old flat history into Gemini
    return { gemini: [], gpt: [], kimi: [] };
  });
  const messages = threads[provider] || [];
  const setMessages = (updater) => setThreads(prev => { const cur = prev[provider] || []; const next = typeof updater === "function" ? updater(cur) : updater; return { ...prev, [provider]: next }; });
  const [input, setInput] = React.useState("");
  const [busy, setBusy] = React.useState(false);
  // v860 — the reply as it is being written, plus how much silent reasoning has
  // happened. Cleared the moment the finished message joins the thread.
  const [streaming, setStreaming] = React.useState("");
  const [thinking, setThinking] = React.useState(0);
  // v07zz290 — images pasted/uploaded with the next message (data URLs). Sent as
  // `images` with the current turn so Gemini/ChatGPT can SEE them.
  const [attachments, setAttachments] = React.useState([]);   // [{ url, name }]
  const fileInputRef = React.useRef(null);
  const addFiles = (fileList) => {
    const files = Array.from(fileList || []).filter(f => f.type && f.type.startsWith("image/"));
    for (const f of files) {
      if (f.size > 8 * 1024 * 1024) continue;   // skip > 8 MB
      const reader = new FileReader();
      reader.onload = () => setAttachments(prev => prev.length >= 6 ? prev : [...prev, { url: String(reader.result), name: f.name || "image" }]);
      reader.readAsDataURL(f);
    }
  };
  const onPaste = (e) => {
    const items = (e.clipboardData && e.clipboardData.items) || [];
    const imgs = [];
    for (const it of items) { if (it.kind === "file" && it.type && it.type.startsWith("image/")) { const f = it.getAsFile(); if (f) imgs.push(f); } }
    if (imgs.length) { e.preventDefault(); addFiles(imgs); }
  };
  const [pos, setPos] = React.useState(() => { const p = readLS(LS.pos, null); return (p && typeof p.x === "number" && typeof p.y === "number") ? p : null; });

  const bubbleRef = React.useRef(null);
  const listRef = React.useRef(null);
  const dragRef = React.useRef(null);
  const justDragged = React.useRef(false);
  const docListeners = React.useRef(null);

  // ── persistence ──
  React.useEffect(() => { try { localStorage.setItem(LS.provider, JSON.stringify(provider)); } catch (_) {} }, [provider]);
  // v07zz290 — strip the (large) image data URLs before persisting so a few pasted
  // images can't blow the localStorage quota; the text history still persists.
  // v717 — persist: TEXT + image KEYS to localStorage, image BYTES to IndexedDB.
  // `lostImages` (v715) is still written as a fallback so a turn whose bytes couldn't be
  // stored — IDB unavailable, private window, evicted by the cap — degrades to the honest
  // "the images were dropped on reload" note instead of pretending they're there.
  React.useEffect(() => {
    let dead = false;
    const wanted = [];            // every key still referenced, oldest → newest
    const writes = [];
    const strip = (arr) => (arr || []).slice(-40).map(m => {
      const { images, ...rest } = m;
      if (!images || !images.length) return rest;
      const id = m.id || _aicMsgId();
      const keys = images.map((_, i) => `${id}:${i}`);
      wanted.push(...keys);
      images.forEach((dataUrl, i) => {
        if (typeof dataUrl !== "string" || !dataUrl.startsWith("data:")) return;
        writes.push(_aicToBlob(dataUrl).then(b => _aicPut(keys[i], b)).catch(() => {}));
      });
      return { ...rest, id, imageKeys: keys, lostImages: images.length };
    });
    try {
      localStorage.setItem(LS.msgs, JSON.stringify({
        gemini: strip(threads.gemini), gpt: strip(threads.gpt), kimi: strip(threads.kimi),
      }));
    } catch (_) {}
    // Prune: anything IDB holds that no thread references any more (cleared chats,
    // turns that fell off the 40-message tail), plus the oldest over the cap.
    Promise.all(writes).then(() => _aicKeys()).then(all => {
      if (dead || !all) return;
      const keep = new Set(wanted.slice(-AIC_MAX_IMAGES));
      const drop = all.filter(k => !keep.has(k));
      if (drop.length) return _aicDel(drop);
    }).catch(() => {});
    return () => { dead = true; };
  }, [threads]);

  // v717 — hydrate: pull the image bytes back out of IDB once, on mount, and put them
  // back on their messages. Text renders immediately; pictures fill in a beat later.
  React.useEffect(() => {
    let dead = false;
    const hydrateThread = async (arr) => {
      let touched = false;
      const out = await Promise.all((arr || []).map(async (m) => {
        if (!m.imageKeys || !m.imageKeys.length || (m.images && m.images.length)) return m;
        const urls = [];
        for (const k of m.imageKeys) {
          try { const b = await _aicGet(k); if (b) urls.push(await _aicToDataUrl(b)); } catch (_) {}
        }
        if (!urls.length) return m;                 // bytes gone → keep the lostImages note
        touched = true;
        const { lostImages, ...rest } = m;          // they're NOT lost after all
        return { ...rest, images: urls };
      }));
      return touched ? out : null;
    };
    (async () => {
      try {
        const [g, p, k] = await Promise.all([hydrateThread(threads.gemini), hydrateThread(threads.gpt), hydrateThread(threads.kimi)]);
        if (dead || (!g && !p && !k)) return;
        setThreads(prev => ({ ...prev, ...(g ? { gemini: g } : {}), ...(p ? { gpt: p } : {}), ...(k ? { kimi: k } : {}) }));
      } catch (_) {}
    })();
    return () => { dead = true; };
    // mount only — re-running would fight the persist effect
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);
  React.useEffect(() => { try { if (pos) localStorage.setItem(LS.pos, JSON.stringify(pos)); } catch (_) {} }, [pos]);
  // autoscroll to newest
  React.useEffect(() => { const el = listRef.current; if (el) el.scrollTop = el.scrollHeight; }, [messages, busy, open]);

  // ── drag the bubble (drag vs click distinguished by a 4px threshold) ──
  const clampPos = (x, y) => {
    const m = 8, w = 58, h = 58;
    const maxX = Math.max(m, window.innerWidth - w - m);
    const maxY = Math.max(m, window.innerHeight - h - m);
    return { x: Math.max(m, Math.min(x, maxX)), y: Math.max(m, Math.min(y, maxY)) };
  };
  const onBubbleDown = (e) => {
    if (e.button !== 0) return;
    const el = bubbleRef.current; if (!el) return;
    const rect = el.getBoundingClientRect();
    dragRef.current = { sx: e.clientX, sy: e.clientY, ox: rect.left, oy: rect.top, moved: false };
    const onMove = (ev) => {
      const d = dragRef.current; if (!d) return;
      const dx = ev.clientX - d.sx, dy = ev.clientY - d.sy;
      if (!d.moved && (Math.abs(dx) > 4 || Math.abs(dy) > 4)) d.moved = true;
      if (d.moved) setPos(clampPos(d.ox + dx, d.oy + dy));
    };
    const onUp = () => {
      const d = dragRef.current; justDragged.current = !!(d && d.moved); dragRef.current = null;
      document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); docListeners.current = null;
    };
    docListeners.current = { onMove, onUp };
    document.addEventListener("mousemove", onMove);
    document.addEventListener("mouseup", onUp);
    e.preventDefault();
  };
  // tear down a mid-drag if the widget unmounts
  React.useEffect(() => () => { const L = docListeners.current; if (L) { document.removeEventListener("mousemove", L.onMove); document.removeEventListener("mouseup", L.onUp); } }, []);
  // v07zz372 — a persisted position saved on a bigger window (or before a resize) could strand the
  // bubble OFF-SCREEN, so it looked like the assistant had vanished. Re-clamp into the current
  // viewport on mount + on every resize.
  React.useEffect(() => {
    const reclamp = () => setPos(p => (p ? clampPos(p.x, p.y) : p));
    reclamp();
    window.addEventListener("resize", reclamp);
    return () => window.removeEventListener("resize", reclamp);
  }, []);
  const onBubbleClick = () => { if (justDragged.current) { justDragged.current = false; return; } setOpen(o => !o); };

  // ── what the user is currently looking at (light context for the model) ──
  const currentContext = () => {
    try {
      let view = "";
      for (const k of ["filmtracker.view", "frameflow.view", "paradise.view"]) { const v = localStorage.getItem(k); if (v) { view = v; break; } }
      const ep = (window.__appData && window.__appData.episode && window.__appData.episode.title) || "";
      return [view ? "the " + view + " page" : "", ep ? "episode “" + ep + "”" : ""].filter(Boolean).join(", ");
    } catch (_) { return ""; }
  };

  // ── send ── (reply shown instantly — the word-by-word reveal was a client-side
  // fake that only made it feel slower, so it was removed.)
  // v07zz640 — monotonic send id. Switching model (or clearing) bumps it, so a reply
  // still in flight from the abandoned request is dropped instead of appearing later
  // under the wrong model. Cheap stand-in for AbortController across all three paths.
  const _sendSeq = React.useRef(0);
  // v715 — ONE dispatch path, shared by Send and Retry. `history` already INCLUDES the
  // user turn being asked about, so a retry replays byte-identically: same messages, same
  // images, same provider. Hugo: "can you add a retry button when this happen that sends
  // back the exact same prompt and images?" — Kimi is a reasoning model and a 180s
  // timeout on a long think is routine, so re-typing the question was the real cost.
  // v716 — a REAL AbortController so Stop actually hangs up. `_sendSeq` alone only made
  // us ignore a late reply; the request itself kept running.
  const _abortRef = React.useRef(null);
  const _dispatch = (history, imgs) => {
    setBusy(true);
    const _mySeq = ++_sendSeq.current;
    const _live = () => _sendSeq.current === _mySeq;
    const ctrl = (typeof AbortController !== "undefined") ? new AbortController() : null;
    _abortRef.current = ctrl;
    const fetcher = window.authFetch || fetch;
    fetcher("/api/assistant/chat", {
      method: "POST", headers: { "Content-Type": "application/json" },
      signal: ctrl ? ctrl.signal : undefined,
      body: JSON.stringify({
        provider,
        context: currentContext(),
        messages: history.filter(m => m.role === "user" || m.role === "assistant").slice(-16).map(m => ({ role: m.role, content: m.content })),
        images: imgs || [],
        // v860 — stream the reply so it types out instead of landing in one lump after
        // the whole answer is written. Server falls back to plain JSON for Gemini.
        stream: true,
      }),
    })
      .then(async (r) => {
        // v860 — SSE when the server streamed, plain JSON otherwise (Gemini, older
        // server). Both end up as { ok, j } so everything below is untouched.
        if (!String(r.headers.get("content-type") || "").includes("text/event-stream")) {
          return r.json().then(j => ({ ok: r.ok, j })).catch(() => ({ ok: r.ok, j: null }));
        }
        const reader = r.body.getReader();
        const dec = new TextDecoder();
        let buf = "", live = "", j = null, lastPaint = 0;
        for (;;) {
          const { done, value } = await reader.read();
          if (done) break;
          buf += dec.decode(value, { stream: true });
          let i;
          while ((i = buf.indexOf("\n\n")) >= 0) {
            const frame = buf.slice(0, i); buf = buf.slice(i + 2);
            const ev = (frame.match(/^event: (.+)$/m) || [])[1];
            const dm = frame.match(/^data: ([\s\S]+)$/m);
            if (!ev || !dm) continue;
            let d = null; try { d = JSON.parse(dm[1]); } catch (_) { continue; }
            if (ev === "delta") {
              live += d.t || "";
              // Repaint ~12x/s. A setState per token would re-render the whole thread.
              const now = Date.now();
              if (now - lastPaint > 80) { lastPaint = now; if (_live()) setStreaming(live); }
            } else if (ev === "thinking") {
              if (_live()) setThinking(d.chars || 0);
            } else if (ev === "done") { j = d; }
            else if (ev === "error") { j = { error: d.error }; }
          }
        }
        // v860 — FINAL PAINT, unthrottled. The 80ms repaint gate means the last burst of
        // deltas can be skipped, leaving the tail of the answer missing until `done`
        // swapped it out. Caught by replaying a stream in 5-byte chunks: 4 deltas landed
        // inside one 80ms window and only the first was ever shown.
        if (_live()) setStreaming(live);
        return { ok: !!(j && !j.error), j };
      })
      .then(({ ok, j }) => {
        if (!_live()) return;   // model switched mid-flight — drop this reply
        if (ok && j && j.reply) setMessages(m => [...m, { role: "assistant", content: j.reply }]);
        else setMessages(m => [...m, { role: "error", content: (j && j.error) || "Something went wrong — please try again.", canRetry: true }]);
      })
      .catch((err) => {
        if (!_live()) return;
        // v716 — Stop lands here. It must NOT read as a failure: nothing broke, Hugo
        // hung up on purpose. Retry is offered because the turn is still intact.
        if (err && err.name === "AbortError") {
          setMessages(m => [...m, { role: "error", content: "Stopped. Your question and images are kept — hit Retry to ask again.", canRetry: true, stopped: true }]);
          return;
        }
        // v07zz642 — this only fires when the SAME-ORIGIN request never got a reply,
        // i.e. Film Tracker itself isn't answering (server stopped, mid-restart, laptop
        // asleep). That is a completely different problem from the model failing, and
        // the old wording ("check your connection") sent us hunting the wrong thing.
        // A real model failure always comes back as a 502 with j.error, handled above.
        setMessages(m => [...m, { role: "error", content: "The Film Tracker server didn't respond — it may have stopped or be restarting. Check the server window, then try again.", canRetry: true }]);
      })
      // v860 — drop the live text once the real message is in the thread, so the
      // streamed copy never lingers underneath the finished reply.
      .finally(() => { if (_live()) { setBusy(false); setStreaming(""); setThinking(0); _abortRef.current = null; } });
  };

  // v716 — Stop. Hugo: "i also need a stop button to interupt the querry."
  const stop = () => {
    const c = _abortRef.current;
    if (!c) return;
    _abortRef.current = null;
    try { c.abort(); } catch (_) {}
  };
  // Esc stops the question. Capture phase + stopPropagation so it hits Stop rather than
  // whatever else on the page listens for Escape — and ONLY while something is in
  // flight, so Esc behaves normally the rest of the time.
  React.useEffect(() => {
    if (!open || !busy) return;
    const onEsc = (e) => {
      if (e.key !== "Escape") return;
      e.preventDefault(); e.stopPropagation();
      if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
      stop();
    };
    window.addEventListener("keydown", onEsc, true);
    return () => window.removeEventListener("keydown", onEsc, true);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [open, busy]);

  const send = () => {
    const text = input.trim();
    const imgs = attachments.map(a => a.url);
    if ((!text && !imgs.length) || busy) return;
    // v717 — a stable id, so this turn's images can be keyed in IndexedDB and found
    // again after a reload.
    const userMsg = { id: _aicMsgId(), role: "user", content: text || "What can you tell me about this image?", images: imgs.length ? imgs : undefined };
    const next = [...messages, userMsg];
    setMessages(next); setInput(""); setAttachments([]);
    _dispatch(next, imgs);
  };

  // v715 — RETRY. Drops the error bubble and replays the turn that produced it, with the
  // images still attached to that user message. It does NOT append a second copy of the
  // question — the transcript ends up exactly as if the first attempt had worked.
  const retry = (errIdx) => {
    if (busy) return;
    const history = messages.slice(0, errIdx);            // everything up to (not incl.) the error
    let lastUser = null;
    for (let i = history.length - 1; i >= 0; i--) { if (history[i].role === "user") { lastUser = history[i]; break; } }
    if (!lastUser) return;
    setMessages(history);
    _dispatch(history, lastUser.images || []);
  };
  const onKey = (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); } };

  // ── icons ──
  // v07zz242 — soft, OUTLINED 4-point sparkle (rounded joins), matching the
  // app's feather-style line icons instead of the previous hard filled star.
  const IconChat = () => (
    <svg viewBox="0 0 24 24" width="25" height="25" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="M12 3 C12.7 9 15 11.3 21 12 C15 12.7 12.7 15 12 21 C11.3 15 9 12.7 3 12 C9 11.3 11.3 9 12 3 Z"/>
    </svg>
  );
  const IconClose = () => (
    <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="2.1" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18" /></svg>
  );

  // ── panel anchoring (opens toward whichever corner has room) ──
  const vh = window.innerHeight, vw = window.innerWidth;
  const bubbleX = pos ? pos.x : vw - 82;   // default bottom-right corner
  const bubbleY = pos ? pos.y : vh - 82;
  const openUp = bubbleY > vh / 2;
  const openLeft = bubbleX > vw / 2;
  const rootStyle = pos ? { left: pos.x + "px", top: pos.y + "px", right: "auto", bottom: "auto" } : undefined;
  const panelCls = "aichat-panel" + (openUp ? " up" : " down") + (openLeft ? " left" : " right");

  // 23 Sep 2026 — Kimi K3 removed (Hugo: "remove Kimi completely from everywhere"). Its saved thread
  // stays in localStorage (threads.kimi) untouched; only the button is gone.
  const PROVIDERS = [{ id: "gemini", label: "Gemini" }, { id: "gpt", label: "ChatGPT" }];

  return (
    <div className="aichat-root" style={rootStyle}>
      {open && (
        <div className={panelCls} onMouseDown={(e) => e.stopPropagation()}>
          <div className="aichat-head">
            <div className="aichat-head-l">
              <div className="aichat-title">Project Assistant</div>
              <div className="aichat-sub">Knows {(window.__appData && window.__appData.episode && (window.__appData.episode.project_name || window.__appData.episode.title)) || "this project"}</div>
            </div>
            <div className="aichat-seg" role="group" aria-label="AI model">
              {PROVIDERS.map(p => (
                // v07zz640 — Hugo: "i cant switch model in the chat assistant now when I
                // have launched a query. and this is bad." The pills were disabled while
                // busy, so a slow or timing-out model locked you into itself — exactly
                // when you most want to jump to another one. Switching is now always
                // allowed: it just picks the model for the NEXT message, and any reply
                // still in flight from the old one is discarded rather than landing in
                // the transcript under the wrong name.
                <button key={p.id} type="button" className={"aichat-seg-btn" + (provider === p.id ? " is-on" : "")}
                  onClick={() => { if (p.id !== provider) { _sendSeq.current++; setBusy(false); setProvider(p.id); } }}
                  title={busy && p.id !== provider ? `Switch to ${p.label} — the pending reply will be dropped` : "Use " + p.label}>{p.label}</button>
              ))}
            </div>
          </div>

          <div className="aichat-list" ref={listRef}>
            {messages.length === 0 && (
              <div className="aichat-empty">
                <div className="aichat-empty-title">Ask me anything about the project.</div>
                <div className="aichat-empty-body">Costumes and props for a period, what a character would plausibly have worn or carried at a moment in history, set dressing, the look of a place in a given year — I know this project's story, eras, characters and locations. I reply in plain conversation.</div>
              </div>
            )}
            {messages.map((m, i) => {
              // v715 — what a Retry on THIS error would resend. Computed here so the
              // button can name it, and so a turn whose images were dropped by the
              // localStorage strip says so rather than quietly resending text only.
              let rt = null;
              if (m.role === "error" && i === messages.length - 1) {
                for (let k = i - 1; k >= 0; k--) {
                  if (messages[k].role === "user") {
                    rt = { n: (messages[k].images || []).length, lost: messages[k].lostImages || 0 };
                    break;
                  }
                }
              }
              return (
              <div key={i} className={"aichat-msg aichat-msg--" + m.role}>
                <div className="aichat-bub">
                  {m.images && m.images.length > 0 && (
                    <div className="aichat-bub-imgs">{m.images.map((u, j) => <img key={j} src={u} alt="attachment" />)}</div>
                  )}
                  {m.content ? <span className="aichat-bub-text">{m.content}</span> : null}
                  {rt && (
                    <div className="aichat-retry-row">
                      <button type="button" className="aichat-retry" onClick={() => retry(i)} disabled={busy}
                        title="Send the same question again, with the same images">
                        <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 11a8 8 0 0 1 14-5.3L20 8M20 4v4h-4"/><path d="M21 13a8 8 0 0 1-14 5.3L4 16M4 20v-4h4"/></svg>
                        {busy ? "Retrying…" : "Retry"}
                      </button>
                      <span className="aichat-retry-note">
                        {rt.n > 0
                          ? `same question + ${rt.n} image${rt.n === 1 ? "" : "s"}`
                          : rt.lost > 0
                            ? `same question — the ${rt.lost} image${rt.lost === 1 ? "" : "s"} were dropped on reload, re-attach ${rt.lost === 1 ? "it" : "them"} to include ${rt.lost === 1 ? "it" : "them"}`
                            : "same question"}
                      </span>
                    </div>
                  )}
                </div>
              </div>
              );
            })}
            {/* v860 — the reply TYPES OUT instead of appearing all at once when it is
                finished. Three states in one slot, so the thread never jumps: the dots
                while we wait, a reasoning counter once the model starts thinking (these
                are reasoning models — they think in silence first), then the text itself
                as it arrives. It is replaced by the real message on `done`. */}
            {busy && (
              <div className="aichat-msg aichat-msg--assistant">
                {streaming
                  ? <div className="aichat-bub aichat-bub--live"><span className="aichat-bub-text">{streaming}</span><span className="aichat-caret" /></div>
                  : thinking
                    ? <div className="aichat-bub aichat-thinking">Thinking… {thinking.toLocaleString()} characters</div>
                    : <div className="aichat-bub aichat-typing"><span /><span /><span /></div>}
              </div>
            )}
          </div>

          {attachments.length > 0 && (
            <div className="aichat-attachments">
              {attachments.map((a, i) => (
                <div key={i} className="aichat-attach">
                  <img src={a.url} alt={a.name} />
                  <button type="button" className="aichat-attach-x" onClick={() => setAttachments(prev => prev.filter((_, j) => j !== i))} aria-label="Remove image">×</button>
                </div>
              ))}
            </div>
          )}
          <div className="aichat-foot">
            {messages.length > 0 && <button type="button" className="aichat-clear" onClick={() => setMessages([])} title="Clear this conversation">Clear</button>}
            {/* v07zz290 — attach images (also: paste an image into the box, or drag one in) */}
            <button type="button" className="aichat-attachbtn" onClick={() => fileInputRef.current && fileInputRef.current.click()} disabled={busy} title="Attach images (you can also paste)" aria-label="Attach images">
              <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
            </button>
            <input ref={fileInputRef} type="file" accept="image/*" multiple style={{ display: "none" }} onChange={(e) => { addFiles(e.target.files); e.target.value = ""; }} />
            <textarea
              className="aichat-input"
              rows={4}
              value={input}
              placeholder={"Ask " + (provider === "gpt" ? "ChatGPT" : provider === "kimi" ? "Kimi" : "Gemini") + "…"}
              onChange={(e) => setInput(e.target.value)}
              onKeyDown={onKey}
              onPaste={onPaste}
              disabled={busy}
            />
            {/* v716 — while a question is in flight the SEND button becomes STOP, in the
                same slot. One button, one place to look; nothing moves, and there is no
                window where both a live Send and a live Stop are clickable. */}
            {busy ? (
              <button type="button" className="aichat-send aichat-send--stop" onClick={stop} aria-label="Stop" title="Stop this question (Esc)">
                <svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" stroke="none"><rect x="6" y="6" width="12" height="12" rx="2.5"/></svg>
              </button>
            ) : (
              <button type="button" className="aichat-send" onClick={send} disabled={!input.trim() && !attachments.length} aria-label="Send">
                <svg viewBox="0 0 24 24" width="20" height="20" 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>
        </div>
      )}

      <button
        ref={bubbleRef}
        type="button"
        className={"aichat-bubble" + (open ? " is-open" : "")}
        onMouseDown={onBubbleDown}
        onClick={onBubbleClick}
        title={open ? "Close assistant" : "Ask the project assistant"}
        aria-label={open ? "Close assistant" : "Open project assistant"}
      >
        {open ? <IconClose /> : <IconChat />}
      </button>
    </div>
  );
}
window.AssistantChat = AssistantChat;
