/* ===========================================================
   indie.chat — React app
   =========================================================== */
const { useState, useEffect, useRef, useCallback } = React;
const L = window.IndieLib;

/* Translate. An arrow rather than a bound reference, so it always reads the
   CURRENT locale — switching languages re-renders the tree and every t() call
   picks up the new catalogue without anything being re-imported. */
const t = (k, v) => L.t(k, v);
const tb = (k, v) => L.tb(k, v);

/* ---------- icons ---------- */
const SendIcon = () => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
    <path d="M12 19V5M5 12l7-7 7 7" />
  </svg>
);
const MenuIcon = () => (
  <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#4d4d51" strokeWidth="2" strokeLinecap="round">
    <path d="M3 6h18M3 12h18M3 18h18" />
  </svg>
);
const ClipIcon = () => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" 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>
);
const SearchIcon = () => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
    <circle cx="11" cy="11" r="7" /><path d="M21 21l-4.3-4.3" />
  </svg>
);
const ShieldIcon = () => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
    <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
  </svg>
);
const MicIcon = () => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
    <rect x="9" y="2" width="6" height="12" rx="3" />
    <path d="M5 11a7 7 0 0 0 14 0M12 18v4" />
  </svg>
);
const DownloadIcon = () => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
    <path d="M12 3v12M8 11l4 4 4-4M5 21h14" />
  </svg>
);
const BookIcon = () => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7"><path d="M4 5.5A1.5 1.5 0 0 1 5.5 4H19v14H5.5A1.5 1.5 0 0 0 4 19.5z"/><path d="M8 8h7M8 11.5h7"/></svg>
);
const ToolsIcon = () => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
    <path d="M14.7 6.3a4 4 0 0 1-5.2 5.2L4 17v3h3l5.5-5.5a4 4 0 0 1 5.2-5.2l-2.5 2.5-2-2 2.5-2.5z" />
  </svg>
);
const FileGenIcon = () => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
    <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
    <path d="M14 2v6h6" />
    <path d="M12 18v-6" /><path d="M9 15h6" />
  </svg>
);
const PlusIcon = () => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M12 5v14M5 12h14" /></svg>
);
const BotIcon = () => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
    <rect x="4" y="8" width="16" height="11" rx="2" /><path d="M12 8V4" /><circle cx="12" cy="3" r="1" />
    <circle cx="9" cy="13" r="1.1" fill="currentColor" /><circle cx="15" cy="13" r="1.1" fill="currentColor" /><path d="M9.5 16.5h5" />
  </svg>
);
const BellIcon = () => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
    <path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" /><path d="M13.7 21a2 2 0 0 1-3.4 0" />
  </svg>
);
const ClockIcon = () => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
    <circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" />
  </svg>
);
const HelpIcon = () => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
    <circle cx="12" cy="12" r="9" /><path d="M9.6 9.3a2.5 2.5 0 0 1 4.9.7c0 1.7-2.5 2-2.5 3.5" /><circle cx="12" cy="17" r="0.9" fill="currentColor" stroke="none" />
  </svg>
);
const GearIcon = () => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
    <circle cx="12" cy="12" r="3" />
    <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
  </svg>
);
const FilesIcon = () => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
    <path d="M4 7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" />
  </svg>
);
const RefreshIcon = () => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
    <path d="M21 12a9 9 0 1 1-2.64-6.36" /><path d="M21 3v6h-6" />
  </svg>
);
const LockIcon = ({ open }) => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
    <rect x="5" y="11" width="14" height="10" rx="2" />
    {open ? <path d="M8 11V7a4 4 0 0 1 7.5-2" /> : <path d="M8 11V7a4 4 0 0 1 8 0v4" />}
  </svg>
);

/* Brand wordmark — reads the (white-labelable) brand name; colors the first dot
   with the accent, so "indie.chat" keeps its look and "acme.ai" gets one too. */
function BrandName() {
  const n = (L.BRAND && L.BRAND.brandName) || "indie.chat";
  /* Accent the separator AND everything after it, and accept ':' as well as
     '.', so "indie.chat" keeps its look while "acme:agents" gets the same
     treatment instead of rendering flat. (From the crefo deployment.) */
  const i = n.search(/[.:]/);
  if (i < 0) return n;
  return <>{n.slice(0, i)}<b className="bd">{n.slice(i)}</b></>;
}

/* ========================================================
   ORIGIN UNREACHABLE

   The last resort, NOT the first response. When the origin cannot be reached
   the app first asks for an uncached navigation (see `forceNetworkReload`), so
   the browser makes a real request and shows its own certificate or connection
   dialog — which explains the problem, and offers the way through it, far
   better than anything we could write here.

   This screen is what is left when that cannot help: the reload has already
   been used this tab, or the browser says it has no network at all (in which
   case the cached app is the point and is left alone). One sentence and a
   button; the detail belongs in the operator's README.
   ======================================================== */
function UnreachableScreen({ offline, onRetry, retrying }) {
  return (
    <div className="auth-screen">
      <div className="auth-card">
        <div className="auth-brand">
          <div className="logo" style={{ width: 38, height: 38, marginRight: 12 }} />
          <div>
            <div className="brand-name"><BrandName /></div>
            <div className="brand-tag">{L.BRAND.tagline}</div>
          </div>
        </div>

        <div className="auth-error">{t("offline.title")}</div>
        <p className="auth-offline">{t(offline ? "offline.offlineBody" : "offline.serverBody")}</p>

        <button className="btn primary auth-submit" onClick={onRetry} disabled={retrying}>
          {retrying ? t("common.pleaseWait") : t("offline.retry")}
        </button>
      </div>
    </div>
  );
}

/* Shown for the moment between deciding to reload and the reload happening.
   Deliberately says nothing about why: the browser is about to. */
function ReloadingScreen() {
  return (
    <div style={{ height: "100%", display: "grid", placeItems: "center", background: "var(--canvas)" }}>
      <span style={{ fontFamily: "var(--mono)", fontSize: 11, color: "var(--ink-4)", letterSpacing: "0.1em", textTransform: "uppercase" }}>
        {t("offline.reloading")}
      </span>
    </div>
  );
}

/* ========================================================
   LOGIN SCREEN
   ======================================================== */
function LoginScreen({ onLogin }) {
  const [tab,      setTab]      = useState("login");
  const [email,    setEmail]    = useState("");
  const [password, setPassword] = useState("");
  const [name,     setName]     = useState("");
  const [loading,  setLoading]  = useState(false);
  const [error,    setError]    = useState(null);
  const [notice,   setNotice]   = useState(null);
  const [reloading, setReloading] = useState(false);

  /* Explicitly hand the credential to the browser's password manager. The form
     submits via fetch (no navigation), so Chrome/Edge's save heuristic doesn't
     fire on its own; the Credential Management API triggers the save prompt.
     Safari/Firefox fall back to the autocomplete attributes on submit. */
  const saveCredential = async (id, pass, nm) => {
    try {
      if (window.PasswordCredential && window.isSecureContext) {
        await navigator.credentials.store(new window.PasswordCredential({ id, password: pass, name: nm || id }));
      }
    } catch { /* user dismissed, or unsupported — harmless */ }
  };

  const submit = async e => {
    e.preventDefault();
    setLoading(true);
    setError(null);
    setNotice(null);
    try {
      if (tab === "login") {
        const u = await L.Auth.login(email.trim(), password);
        await saveCredential(email.trim(), password, u && u.name);
        onLogin(u);
      } else {
        const res = await L.Auth.register(email.trim(), name.trim(), password);
        /* Save the new credential now (works whether or not approval is pending). */
        await saveCredential(email.trim(), password, name.trim());
        if (res && res.pending) {
          /* Awaiting admin approval — don't log in; send them back to sign-in. */
          setTab("login");
          setName(""); setPassword("");
          setNotice(res.message || t("auth.pendingApproval"));
        } else {
          onLogin(res);
        }
      }
    } catch (err) {
      /* A request that never reached the server is not a rejected credential —
         reporting it as one is what sent people off to reset a working
         password. It cannot succeed on a retry either, so hand the problem to
         the browser: drop the cached app and reload, and let it show its own
         certificate dialog. Only if that is not available do we say anything. */
      if (L.isTransportFailure(err) && L.canForceNetworkReload()) {
        setReloading(true);
        L.forceNetworkReload();
        return;                      /* the page is going away; leave it loading */
      }
      setError(err.message || t("err.generic"));
    } finally {
      setLoading(false);
    }
  };

  if (reloading) return <ReloadingScreen />;

  return (
    <div className="auth-screen">
      <div className="auth-card">
        <div className="auth-brand">
          <div className="logo" style={{ width: 38, height: 38, marginRight: 12 }} />
          <div>
            <div className="brand-name"><BrandName /></div>
            <div className="brand-tag">{L.BRAND.tagline}</div>
          </div>
        </div>

        <div className="auth-tabs">
          <button className={"auth-tab " + (tab === "login"    ? "on" : "")} onClick={() => { setTab("login");    setError(null); }}>{t("auth.signIn")}</button>
          <button className={"auth-tab " + (tab === "register" ? "on" : "")} onClick={() => { setTab("register"); setError(null); setNotice(null); }}>{t("auth.register")}</button>
        </div>

        <form onSubmit={submit} className="auth-form">
          {error  && <div className="auth-error">{error}</div>}
          {notice && <div className="auth-notice">{notice}</div>}
          {tab === "register" && !notice && (
            <div className="auth-note-sm">{t("auth.approvalNote")}</div>
          )}
          {tab === "register" && (
            <div className="field">
              <label>{t("auth.name")}</label>
              <input name="name" autoComplete="name" value={name} onChange={e => setName(e.target.value)} placeholder={t("auth.namePlaceholder")} required autoFocus />
            </div>
          )}
          <div className="field">
            <label>{t("auth.email")}</label>
            <input type="email" name="email" autoComplete="username" value={email} onChange={e => setEmail(e.target.value)}
                   placeholder="you@example.com" required autoFocus={tab === "login"} />
          </div>
          <div className="field">
            <label>{t("auth.password")}</label>
            <input type="password" name="password"
                   autoComplete={tab === "register" ? "new-password" : "current-password"}
                   value={password} onChange={e => setPassword(e.target.value)}
                   placeholder={tab === "register" ? t("auth.passwordHint") : "••••••••"} required />
          </div>
          <button type="submit" className="btn primary auth-submit" disabled={loading}>
            {loading ? t("common.pleaseWait") : (tab === "login" ? t("auth.signInAction") : t("auth.createAccount"))}
          </button>
        </form>

      </div>
    </div>
  );
}

/* ========================================================
   ROUTINES — scheduled prompts (cron) that run on the server
   ======================================================== */
const DOW = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
function cronToText(cron) {
  const f = String(cron || "").trim().split(/\s+/);
  if (f.length !== 5) return cron;
  const [m, h, dom, mon, dow] = f;
  const at = (hh, mm) => (((+hh % 12) || 12) + ":" + String(+mm).padStart(2, "0") + (+hh < 12 ? " AM" : " PM"));
  if (cron === "* * * * *") return t("cadence.minute");
  if (m === "0" && h === "*" && dom === "*" && mon === "*" && dow === "*") return t("cadence.hourly");
  if (/^\*\/\d+$/.test(m) && h === "*") return "Every " + m.slice(2) + " minutes";
  if (/^\d+$/.test(m) && /^\d+$/.test(h) && dom === "*" && mon === "*" && dow === "*") return t("cadence.dailyAt") + " " + at(h, m);
  if (/^\d+$/.test(m) && /^\d+$/.test(h) && dom === "*" && mon === "*" && dow === "1-5") return t("cadence.weekdaysAt") + " " + at(h, m);
  if (/^\d+$/.test(m) && /^\d+$/.test(h) && dom === "*" && mon === "*" && /^\d+$/.test(dow)) return "Every " + DOW[+dow % 7] + " at " + at(h, m);
  return cron;
}
/* The inverse: recover the preset + its inputs from a stored cron, so EDITING a
   routine opens the same controls that created it instead of dropping the user
   into raw cron. Anything we can't recognise stays custom, which is lossless. */
function cronToParts(cron) {
  const f = String(cron || "").trim().split(/\s+/);
  const out = { kind: "custom", time: "09:00", dow: 1, cron: String(cron || "0 9 * * *") };
  if (f.length !== 5) return out;
  const [m, h, dom, mon, dw] = f;
  const hhmm = () => String(+h).padStart(2, "0") + ":" + String(+m).padStart(2, "0");
  if (cron.trim() === "* * * * *") return { ...out, kind: "minute" };
  if (m === "0" && h === "*" && dom === "*" && mon === "*" && dw === "*") return { ...out, kind: "hourly" };
  if (/^\*\/30$/.test(m) && h === "*" && dom === "*" && mon === "*" && dw === "*") return { ...out, kind: "30min" };
  if (/^\d+$/.test(m) && /^\d+$/.test(h) && dom === "*" && mon === "*") {
    if (dw === "*")   return { ...out, kind: "daily",    time: hhmm() };
    if (dw === "1-5") return { ...out, kind: "weekdays", time: hhmm() };
    if (/^\d+$/.test(dw)) return { ...out, kind: "weekly", time: hhmm(), dow: +dw % 7 };
  }
  return out;
}

/* ============================================================
   ROUTINES — the ONE place scheduled work lives.

   A routine is a prompt plus a cadence. It runs in one of two modes:
     chat  — answers into its own conversation, on a provider + model you pick
     agent — runs a full turn on an agent (its workspace, files and tools),
             landing in that agent's transcript as a ⏰ turn. An enabled agent
             routine is what the sidebar calls a LOOP, and it's what promotes
             an agent into the Background tab.
   The mode is a field on the routine, not a different feature, so the same
   list, the same editor and the same controls cover both — and a routine can
   be moved between them without being recreated.
   ============================================================ */
/* Built per render, not once at load: a const would freeze these labels in
   whatever language the page happened to start in. Same for the other
   translated lists below. */
const routineCadences = () => [
  { key: "minute",   label: t("cadence.minuteTest") },
  { key: "30min",    label: t("cadence.halfHour") },
  { key: "hourly",   label: t("cadence.hourly") },
  { key: "daily",    label: t("cadence.daily") },
  { key: "weekdays", label: t("cadence.weekdays") },
  { key: "weekly",   label: t("cadence.weekly") },
  { key: "custom",   label: t("cadence.custom") },
];

function RoutinesPanel({ onClose, model, onOpenConv, onOpenAgent, agents, focusAgentId, onChanged }) {
  const [routines, setRoutines] = useState(null);
  const [err, setErr] = useState(null);
  const [busy, setBusy] = useState(false);
  const [view, setView] = useState("list");        /* list | editor */
  const [editId, setEditId] = useState(null);      /* null = creating */
  const [filter, setFilter] = useState(focusAgentId ? "agent" : "all");
  const [confirmDel, setConfirmDel] = useState(null);

  const mcpAvail    = L.toolEnabled("mcp");
  const skillsAvail = L.enabledSkills().length > 0;
  const searchAvail = L.toolEnabled("search");
  const provs = L.providersList();
  const agentList = agents || [];

  /* ---- the editor's form ---- */
  const blank = () => ({
    title: "", prompt: "",
    mode: focusAgentId ? "agent" : "chat",
    agentId: focusAgentId || (agentList[0] && agentList[0].id) || "",
    provider: L.getProvider() || (provs[0] && provs[0].name),
    model: model || "",
    kind: "daily", time: "09:00", dow: 1, cron: "0 9 * * *",
    tools: false, search: false, notify: true, quiet: "",
  });
  const [f, setF] = useState(blank);
  const set = (k, v) => setF(prev => ({ ...prev, [k]: v }));
  const fModels = L.modelsFor(f.provider);

  /* Keep the model valid for the chosen provider — and for the chosen MODE.
     An agent routine carries no model, so converting one to chat arrives here
     with model:"" and must be defaulted, or it would save a chat routine with
     no model to answer from. */
  useEffect(() => {
    if (f.mode !== "chat") return;
    const m = L.modelsFor(f.provider);
    if (!m.some(x => x.id === f.model)) set("model", (m[0] && m[0].id) || "");
  }, [f.provider, f.mode]);

  /* ---- push notifications (shared by every routine on this device) ---- */
  const [pushState, setPushState] = useState({ supported: false, permission: "default", subscribed: false });
  const [pushBusy, setPushBusy] = useState(false);
  const refreshPush = () => L.Push.status().then(setPushState).catch(() => {});
  useEffect(() => { refreshPush(); }, []);
  const enableNotifications = async () => {
    setPushBusy(true); setErr(null);
    try { await L.Push.enable(); await L.Push.test(); await refreshPush(); }
    catch (e) { setErr(String(e.message || e)); }
    finally { setPushBusy(false); }
  };

  const load = () => L.RoutinesAPI.list().then(setRoutines).catch(e => setErr(String(e.message || e)));
  useEffect(() => { load(); }, []);
  const changed = () => { load(); onChanged && onChanged(); };

  const buildCron = () => {
    const [hh, mm] = String(f.time || "09:00").split(":");
    if (f.kind === "minute")   return "* * * * *";
    if (f.kind === "30min")    return "*/30 * * * *";
    if (f.kind === "hourly")   return "0 * * * *";
    if (f.kind === "daily")    return `${+mm} ${+hh} * * *`;
    if (f.kind === "weekdays") return `${+mm} ${+hh} * * 1-5`;
    if (f.kind === "weekly")   return `${+mm} ${+hh} * * ${f.dow}`;
    return String(f.cron || "").trim();
  };

  const openNew = () => { setF(blank()); setEditId(null); setErr(null); setView("editor"); };
  const openEdit = r => {
    const parts = cronToParts(r.cron);
    const ep = provs.find(p => p.name === r.endpoint);
    setF({
      title: r.title || "", prompt: r.prompt || "",
      mode: r.mode || (r.agentId ? "agent" : "chat"),
      agentId: r.agentId || focusAgentId || (agentList[0] && agentList[0].id) || "",
      provider: (ep && ep.name) || L.getProvider() || (provs[0] && provs[0].name),
      model: r.model || "",
      kind: parts.kind, time: parts.time, dow: parts.dow, cron: parts.cron,
      tools: !!r.mcp, search: !!r.search, notify: r.notify !== false, quiet: r.quiet || "",
    });
    setEditId(r.id); setErr(null); setView("editor");
  };

  const save = async () => {
    setErr(null);
    if (!f.title.trim() || !f.prompt.trim()) { setErr(t("routine.errNeedTitle")); return; }
    const cron = buildCron();
    if (cron.trim().split(/\s+/).length !== 5) { setErr("A cron expression needs 5 fields: min hour dom mon dow."); return; }
    let body = {
      title: f.title.trim(), prompt: f.prompt.trim(), cron,
      tzOffset: new Date().getTimezoneOffset(), notify: f.notify, redact: "off",
      quiet: f.quiet.trim(),
    };
    if (f.mode === "agent") {
      if (!f.agentId) { setErr(t("routine.errNeedAgent")); return; }
      /* The agent brings its own model, tools and workspace, so none of the
         chat-side config applies — send null to clear it on a converted row. */
      body = { ...body, agentId: f.agentId };
    } else {
      const ep = provs.find(e => e.name === f.provider);
      if (!ep || !ep.base) { setErr(t("routine.errNeedProvider")); return; }
      if (!f.model) { setErr(t("routine.errNeedModel")); return; }
      body = {
        ...body, agentId: null,
        url: ep.base + ep.path, model: f.model, endpoint: ep.name,
        mcp: f.tools && mcpAvail, servers: f.tools ? L.enabledMcpServers() : [],
        skills: f.tools ? L.enabledSkills() : [],
        search: f.search && searchAvail,
      };
    }
    setBusy(true);
    try {
      if (editId) await L.RoutinesAPI.update(editId, body);
      else await L.RoutinesAPI.create({ ...body, enabled: true });
      setView("list"); setEditId(null);
      changed();
    } catch (e) { setErr(String(e.message || e)); }
    finally { setBusy(false); }
  };

  const toggle = async r => { try { await L.RoutinesAPI.update(r.id, { enabled: !r.enabled }); } catch (e) { setErr(String(e.message || e)); } changed(); };
  const runNow = async r => { try { await L.RoutinesAPI.runNow(r.id); } catch (e) { setErr(String(e.message || e)); } setTimeout(changed, 1200); };
  const del    = async r => { try { await L.RoutinesAPI.remove(r.id); } catch (e) { setErr(String(e.message || e)); } setConfirmDel(null); changed(); };

  const fmt = ms => ms ? new Date(ms).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : "—";
  const agentName = id => { const a = agentList.find(x => x.id === id); return a ? (a.name || "Agent") : "deleted agent"; };
  const isAgent = r => (r.mode || (r.agentId ? "agent" : "chat")) === "agent";

  const all = routines || [];
  const shown = all.filter(r => {
    if (focusAgentId && filter === "agent") return r.agentId === focusAgentId;
    if (filter === "chat")  return !isAgent(r);
    if (filter === "agent") return isAgent(r);
    return true;
  });
  const counts = { all: all.length, chat: all.filter(r => !isAgent(r)).length, agent: all.filter(isAgent).length };

  /* ---------------- editor ---------------- */
  const editor = (
    <>
      <div className="field"><label>{t("common.title")}</label>
        <input value={f.title} onChange={e => set("title", e.target.value)}
               placeholder={f.mode === "agent" ? t("routine.egTitle1") : t("routine.egTitle2")} /></div>
      <div className="field">
        <label>{f.mode === "agent" ? t("routine.missionLabel") : "Prompt"}</label>
        <textarea value={f.prompt} onChange={e => set("prompt", e.target.value)} rows={3}
                  placeholder={f.mode === "agent"
                    ? t("routine.egPrompt1")
                    : t("routine.egPrompt2")} /></div>

      <div className="field"><label>{t("routine.runsAs")}</label>
        <div className="mode-seg">
          <button className={f.mode === "chat" ? "on" : ""} onClick={() => set("mode", "chat")}>
            <ClockIcon /> Chat
            <span>{t("routine.chatModeSub")}</span>
          </button>
          <button className={f.mode === "agent" ? "on" : ""} onClick={() => set("mode", "agent")}
                  disabled={agentList.length === 0}>
            <BotIcon /> Agent
            <span>{agentList.length === 0 ? t("routine.noAgents") : t("routine.agentModeSub")}</span>
          </button>
        </div>
      </div>

      {f.mode === "agent" ? (
        <>
          <div className="field"><label>Agent</label>
            <select value={f.agentId} onChange={e => set("agentId", e.target.value)}>
              {agentList.map(a => <option key={a.id} value={a.id}>{a.name || "Agent"}{a.scope === "global" ? " (shared)" : ""}</option>)}
            </select></div>
          <div className="note">{tb("routine.agentModeNote", { tab: t("sidebar.background") })}</div>
        </>
      ) : (
        <>
          <div className="routine-grid">
            <div className="field"><label>{t("topbar.provider")}</label>
              <select value={f.provider} onChange={e => set("provider", e.target.value)}>
                {provs.map(p => <option key={p.name} value={p.name}>{L.providerLabel(p.name) || p.name}</option>)}
              </select></div>
            <div className="field"><label>{t("topbar.model")}</label>
              <select value={f.model} onChange={e => set("model", e.target.value)}>
                {fModels.map(m => <option key={m.id} value={m.id}>{m.label}</option>)}
              </select></div>
          </div>
          <div className="routine-row">
            {searchAvail && (
              <button className={"tool-toggle " + (f.search ? "on" : "")} onClick={() => set("search", !f.search)}>
                <SearchIcon /> {t("tools.search")}
              </button>
            )}
            {(mcpAvail || skillsAvail) && (
              <button className={"tool-toggle " + (f.tools ? "on" : "")} onClick={() => set("tools", !f.tools)}>
                <ToolsIcon /> Tools
              </button>
            )}
          </div>
        </>
      )}

      <div className="routine-grid">
        <div className="field"><label>{t("routine.cadence")}</label>
          <select value={f.kind} onChange={e => set("kind", e.target.value)}>
            {routineCadences().map(c => <option key={c.key} value={c.key}>{c.label}</option>)}
          </select></div>
        {(f.kind === "daily" || f.kind === "weekly" || f.kind === "weekdays") && (
          <div className="field"><label>{t("routine.time")}</label>
            <input type="time" value={f.time} onChange={e => set("time", e.target.value)} /></div>
        )}
        {f.kind === "weekly" && (
          <div className="field"><label>{t("routine.day")}</label>
            <select value={f.dow} onChange={e => set("dow", +e.target.value)}>
              {DOW.map((d, i) => <option key={i} value={i}>{d}</option>)}
            </select></div>
        )}
        {f.kind === "custom" && (
          <div className="field"><label>{t("routine.cron")}</label>
            <input value={f.cron} onChange={e => set("cron", e.target.value)} placeholder="0 9 * * 1-5" spellCheck="false" /></div>
        )}
      </div>

      <div className="routine-row">
        <button className={"tool-toggle " + (f.notify ? "on" : "")} onClick={() => set("notify", !f.notify)}
                title={t("routine.notifyHint")}>
          <BellIcon /> {t("routine.notify")}
        </button>
        <span className="hint">{t("routine.localTime")} <code>{buildCron()}</code></span>
      </div>

      {/* "Nothing to report" should not buzz a phone: an answer that starts
          with this text is kept in the transcript but sends no push. */}
      <div className="field"><label>{t("routine.quiet")}</label>
        <input value={f.quiet} onChange={e => set("quiet", e.target.value)}
               placeholder={t("routine.quietPlaceholder")} maxLength={120} spellCheck="false" />
        <span className="hint">{t("routine.quietHint")}</span></div>

      {L.Push.supported() && f.notify && !(pushState.subscribed && pushState.permission === "granted") && (
        <div className="push-row">
          <button className="btn" onClick={enableNotifications} disabled={pushBusy}>
            <BellIcon /> {pushBusy ? t("push.enabling") : t("push.enable")}
          </button>
        </div>
      )}

      {err && <div className="auth-error" style={{ marginTop: 4 }}>{err}</div>}
    </>
  );

  /* ---------------- list ---------------- */
  const list = (
    <>
      <div className="note">{tb("routine.panelNote")}</div>

      <div className="routine-filters">
        {[["all", t("common.all")], ["chat", "Chat"], ["agent", "Agent"]].map(([k, lbl]) => (
          <button key={k} className={"rf-tab " + (filter === k ? "on" : "")} onClick={() => setFilter(k)}>
            {lbl} <span>{counts[k]}</span>
          </button>
        ))}
        <span style={{ flex: 1 }} />
        <button className="btn primary" onClick={openNew}>+ {t("routine.new")}</button>
      </div>
      {focusAgentId && filter === "agent" && (
        <div className="hint" style={{ marginTop: -4 }}>
          {tb("routine.filterNote", { all: t("common.all") })}
        </div>
      )}

      {routines == null ? <div className="note">{t("common.loading")}</div>
        : shown.length === 0 ? (
          <div className="note">
            {all.length === 0 ? t("routine.empty")
                              : t("routine.emptyFilter")}
          </div>
        ) : (
          <div className="routine-list">
            {shown.map(r => (
              <div className={"routine-item " + (r.enabled ? "" : "off")} key={r.id}>
                <div className="ri-main">
                  <div className="ri-title">
                    <span className={"ri-mode " + (isAgent(r) ? "agent" : "chat")}>
                      {isAgent(r) ? "◉ agent" : "⏰ chat"}
                    </span>
                    {r.title}
                  </div>
                  <div className="ri-meta">
                    <span>{cronToText(r.cron)}</span>
                    <span className="ri-dot">·</span>
                    <span>{isAgent(r)
                      ? agentName(r.agentId)
                      : (L.findModel(r.model) ? L.findModel(r.model).label : r.model)}</span>
                    {!isAgent(r) && r.search && <><span className="ri-dot">·</span><span>{t("tools.search")}</span></>}
                    {!isAgent(r) && r.mcp && <><span className="ri-dot">·</span><span>{t("tools.tools")}</span></>}
                    {r.notify && <><span className="ri-dot">·</span><span>{t("routine.notify")}</span></>}
                    {r.quiet && <><span className="ri-dot">·</span><span title={r.quiet}>{t("routine.quietBadge")}</span></>}
                    {r.precheck && <><span className="ri-dot">·</span><span title={t("routine.precheckHint")}>{t("routine.precheckBadge", { tool: r.precheck.tool })}</span></>}
                  </div>
                  <div className="ri-meta sub">
                    {r.enabled ? <>Next: {fmt(r.nextRun)}</> : <>{t("routine.paused")}</>}
                    {r.lastRun && <><span className="ri-dot">·</span>Last: {fmt(r.lastRun)} ({r.lastStatus || "—"})</>}
                  </div>
                  <div className="ri-prompt">{r.prompt}</div>
                </div>
                <div className="ri-actions">
                  {confirmDel === r.id ? (
                    <>
                      <span className="ri-confirm">{t("common.deleteQ")}</span>
                      <button className="act-btn danger" onClick={() => del(r)}>{t("common.deleteYes")}</button>
                      <button className="act-btn" onClick={() => setConfirmDel(null)}>{t("common.cancel")}</button>
                    </>
                  ) : (
                    <>
                      {isAgent(r)
                        ? <button className="act-btn" onClick={() => { onOpenAgent && onOpenAgent(r.agentId); onClose(); }}>{t("routine.openAgent")}</button>
                        : r.convId && <button className="act-btn" onClick={() => { onOpenConv(r.convId); onClose(); }}>{t("common.open")}</button>}
                      <button className="act-btn" onClick={() => runNow(r)}>{t("routine.runNow")}</button>
                      <button className="act-btn" onClick={() => toggle(r)}>{r.enabled ? "◼ Pause" : "▶ Resume"}</button>
                      <button className="act-btn" onClick={() => openEdit(r)}>{t("common.edit")}</button>
                      <button className="act-btn danger" onClick={() => setConfirmDel(r.id)}>{t("common.delete")}</button>
                    </>
                  )}
                </div>
              </div>
            ))}
          </div>
        )}
      {err && <div className="auth-error">{err}</div>}
    </>
  );

  return (
    <div className="modal-scrim" onMouseDown={e => { if (e.target === e.currentTarget) onClose(); }}>
      <div className="modal routines-modal">
        <div className="modal-head">
          <div>
            <span className="eb">{view === "list" ? t("routine.subtitle") : (editId ? t("common.edit") : t("common.new"))}</span>
            <h2>{view === "list" ? t("routine.nav") : (editId ? t("routine.edit") : t("routine.new"))}</h2>
          </div>
          <button className="modal-x" onClick={onClose}>✕</button>
        </div>
        <div className="modal-body">{view === "list" ? list : editor}</div>
        {view === "editor" && (
          <div className="modal-foot">
            <span style={{ flex: 1 }} />
            <button className="btn" onClick={() => { setView("list"); setErr(null); }}>{t("common.cancel")}</button>
            <button className="btn primary" onClick={save} disabled={busy}>
              {busy ? "Saving…" : (editId ? t("common.saveChanges") : t("routine.create"))}
            </button>
          </div>
        )}
      </div>
    </div>
  );
}

/* ---------- markdown body ---------- */
function Markdown({ text, mermaid }) {
  const ref = useRef(null);
  useEffect(() => { if (!ref.current) return; L.decorateCode(ref.current); if (mermaid) renderMermaidIn(ref.current); });
  return (
    <div className="msg-body md" ref={ref}
         dangerouslySetInnerHTML={{ __html: L.renderMarkdown(text) }} />
  );
}

/* ---------- generated-file download card ---------- */
const faLabel = () => ({ xlsx: t("file.xlsx"), docx: t("file.docx"), pdf: t("file.pdf") });
function FileArtifact({ art }) {
  const [state, setState] = useState("idle");   /* idle | working | done | error */
  const [err, setErr] = useState("");
  const kind = art.kind;
  const base = (art.title || "document").replace(/\.[a-z0-9]{2,5}$/i, "").trim() || "document";
  const name = base + "." + kind;
  const download = async () => {
    if (state === "working" || art.pending) return;
    setState("working"); setErr("");
    try { await L.generateFile(kind, art.title || base, art.body); setState("done"); }
    catch (e) { setErr((e && e.message) || t("file.genFailed")); setState("error"); }
  };
  return (
    <div className={"file-art " + (art.pending ? "pending" : "")}>
      <span className="fa-ic"><FileGenIcon /></span>
      <span className="fa-meta">
        <span className="fa-name">{name}</span>
        <span className="fa-kind">
          {art.pending ? t("file.preparing") : (faLabel()[kind] || kind.toUpperCase())}
          {state === "error" && <span className="fa-err"> · {err}</span>}
        </span>
      </span>
      {!art.pending && (
        <button className="fa-btn" onClick={download} disabled={state === "working"}>
          {state === "working" ? t("file.generating") : state === "done" ? t("file.downloadAgain") : (state === "error" ? "Retry" : "Download")}
        </button>
      )}
    </div>
  );
}

/* A file the agent produced this turn — download straight from its workspace. */
function AgentFileCard({ file, onDownload }) {
  const [state, setState] = useState("idle");   /* idle | working | error */
  const [err, setErr] = useState("");
  const go = async () => {
    if (state === "working") return;
    setState("working"); setErr("");
    try { await onDownload(file.path); setState("idle"); }
    catch (e) { setErr((e && e.message) || t("file.downloadFailed")); setState("error"); }
  };
  return (
    <div className="file-art">
      <span className="fa-ic"><FileGenIcon /></span>
      <span className="fa-meta">
        <span className="fa-name">{file.path}</span>
        <span className="fa-kind">
          {fmtBytes(file.size)}
          {state === "error" && <span className="fa-err"> · {err}</span>}
        </span>
      </span>
      <button className="fa-btn" onClick={go} disabled={state === "working"}>
        {state === "working" ? t("file.downloading") : state === "error" ? "Retry" : "Download"}
      </button>
    </div>
  );
}

/* ---------- one tool call ----------
   Collapsed it is a single line. Opened it shows the FULL arguments and the
   full result — the command that actually ran, the path it wrote, the payload
   it got back — which the summary line necessarily truncates. */
/* A tool result often ENDS in a link the user is meant to click — an Excel
   export, a generated report. The compact row would show it as truncated plain
   text, so surface the first markdown link as a real anchor.
   (Contributed back from the crefo deployment.) */
function toolResultLink(res) {
  if (!res) return null;
  const m = String(res).match(/\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/);
  if (!m) return null;
  return {
    label: m[1] || "Open",
    url: m[2],
    download: /\/(export|download)|\.(xlsx|xls|csv|docx|pdf|zip|json)(\?|$)/i.test(m[2]),
  };
}

function ToolStep({ step }) {
  const [open, setOpen] = useState(false);
  const args = step.arguments;
  const argText = args == null ? "" : (typeof args === "string" ? args : JSON.stringify(args));
  const res = step.result;
  const link = toolResultLink(res);
  const hasDetail = !!argText || res != null;
  /* Tool args and results are usually JSON, often JSON-in-a-string; pretty-print
     when it parses, show it verbatim when it doesn't. */
  const pretty = v => {
    if (v == null) return "";
    let out;
    if (typeof v === "string") { try { out = JSON.stringify(JSON.parse(v), null, 2); } catch { out = v; } }
    else { try { out = JSON.stringify(v, null, 2); } catch { out = String(v); } }
    return out.length > 20000 ? out.slice(0, 20000) + "\n… truncated for display" : out;
  };
  return (
    <div className={"tool-step " + (step.isError ? "err " : "") + (open ? "open" : "")}>
      <button className="ts-row" onClick={() => hasDetail && setOpen(o => !o)}
              title={hasDetail ? (open ? t("tool.hideDetails") : t("tool.showDetails")) : undefined}>
        <span className="ts-ic"><ToolsIcon /></span>
        <span className="ts-name">{step.name}</span>
        <span className="ts-args">{argText}</span>
        <span className="ts-res">{res == null ? "…" : "→ " + String(res).replace(/\s+/g, " ").slice(0, 120)}</span>
        {hasDetail && <span className="ts-caret">{open ? "▾" : "▸"}</span>}
      </button>
      {link && (
        <a className="ts-link" href={link.url} target="_blank" rel="noopener noreferrer"
           {...(link.download ? { download: "" } : {})}>
          {link.download ? "⬇ " : "↗ "}{link.label}
        </a>
      )}
      {open && (
        <div className="ts-detail">
          {argText && (<>
            <div className="ts-lbl mono">{t("tool.arguments")}</div>
            <pre>{pretty(args)}</pre>
          </>)}
          <div className="ts-lbl mono">{res == null ? t("tool.resultRunning") : step.isError ? "Error" : "Result"}</div>
          {res != null && <pre>{pretty(res)}</pre>}
        </div>
      )}
    </div>
  );
}

/* ---------- one message ---------- */
function Message({ m, modelLabel, provider, requestedLabel, mismatch, streaming, isLast, busy, onEdit, onRegenerate, onDownloadFile }) {
  const [copied, setCopied] = useState(false);
  const [editing, setEditing] = useState(false);
  const [draft, setDraft] = useState("");
  const [reasonOpen, setReasonOpen] = useState(null);   /* null = auto */
  const isUser = m.role === "user";

  /* Reasoning may arrive as a dedicated field (reasoning_content) and/or inline
     as <think>…</think> in the content. Merge both; the answer is content with
     any <think> block stripped out. */
  const split = !isUser && L.splitThink ? L.splitThink(m.content || "") : { think: "", rest: m.content || "" };
  const reasoning = !isUser ? [m.reasoning, split.think].filter(Boolean).join("\n").trim() : "";
  const answer = isUser ? m.content : split.rest;
  /* Pull any ```file:<kind> blocks out of the answer -> download cards. */
  const arts = (!isUser && L.extractFileArtifacts) ? L.extractFileArtifacts(answer) : { text: answer, artifacts: [] };
  const bodyText  = isUser ? m.content : arts.text;
  const artifacts = arts.artifacts || [];
  const autoOpen = streaming && !(answer || "").trim();   /* expand while still thinking */
  const reasonShown = reasonOpen === null ? autoOpen : reasonOpen;

  const copy = () => {
    /* L.copyText falls back for insecure origins (plain-http LAN deployments),
       where navigator.clipboard does not exist at all. */
    Promise.resolve(L.copyText(isUser ? m.content : (bodyText || ""))).then(ok => {
      if (ok !== false) { setCopied(true); setTimeout(() => setCopied(false), 1200); }
    });
  };
  const startEdit = () => { setDraft(m.content || ""); setEditing(true); };
  const saveEdit  = () => { const t = draft.trim(); if (t) { onEdit(t); setEditing(false); } };
  return (
    <div className={"msg " + (isUser ? "user" : "bot")}>
      <div className="msg-head">
        <span className="msg-sq" />
        <span className="msg-role">{isUser ? "You" : modelLabel}</span>
        {isUser && m.scheduled && <span className="msg-provider" title={t("msg.scheduledHint")}>⏰ scheduled</span>}
        {!isUser && provider && <span className="msg-provider">via {provider}</span>}
        {m.ts && <span className="msg-time">{L.clock(m.ts)}</span>}
      </div>
      {!isUser && mismatch && (
        <div className="model-warn">
          <span className="warn-ic">⚠</span>
          {tb("msg.modelMismatch", { asked: requestedLabel, provider: provider || t("msg.theProvider"), got: modelLabel })}
        </div>
      )}
      {m.searched && (
        <div className="search-pill"><span className="tt-dot" /> {t("msg.searchedWeb")}</div>
      )}
      {m.redactions != null && (
        <div className="redaction-note">
          <span className="rn-ic"><ShieldIcon /></span>
          {m.redactions
            ? <span>{t("msg.redactedBefore")} · <b>{L.redactionSummary(m.redactionTypes) || t("msg.redactedItems", { n: m.redactions })}</b></span>
            : <span>{t("msg.nothingRedacted")}</span>}
        </div>
      )}
      {m.images && m.images.length > 0 && (
        <div className="msg-images">
          {m.images.map((im, i) => (
            <img key={i} className="msg-img" src={im.dataUrl} alt={im.name || "image"} title={im.name || ""} />
          ))}
        </div>
      )}
      {m.attachments && m.attachments.length > 0 && (
        <div className="msg-attach">
          {m.attachments.map((a, i) => (
            <div key={i} className={"chip " + (a.binary ? "bin" : "")}>
              <span className="chip-ic" />
              <span className="chip-name">{a.name}</span>
              <span className="chip-sz">{a.size != null ? fmtSize(a.size) : ""}</span>
            </div>
          ))}
        </div>
      )}
      {!isUser && m.toolSteps && m.toolSteps.length > 0 && (
        <div className="tool-steps">
          {m.toolSteps.map((s, i) => <ToolStep key={i} step={s} />)}
        </div>
      )}
      {isUser
        ? (editing
            ? <div className="msg-edit">
                <textarea value={draft} autoFocus rows={Math.min(12, (draft.match(/\n/g) || []).length + 2)}
                          onChange={e => setDraft(e.target.value)}
                          onKeyDown={e => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); saveEdit(); } if (e.key === "Escape") setEditing(false); }} />
                <div className="edit-actions">
                  <button className="act-btn" onClick={() => setEditing(false)}>{t("common.cancel")}</button>
                  <button className="act-btn primary" onClick={saveEdit} disabled={!draft.trim()}>{t("msg.saveSubmit")}</button>
                </div>
              </div>
            : <div className="msg-body">{m.content}</div>)
        : (
          <>
            {reasoning && (
              <div className="reason">
                <button className="reason-toggle" onClick={() => setReasonOpen(!reasonShown)}>
                  <span className="reason-caret">{reasonShown ? "▾" : "▸"}</span>
                  <span className="reason-label">{autoOpen ? t("msg.thinking") : t("msg.reasoning")}</span>
                </button>
                {reasonShown && <div className="reason-body">{reasoning}</div>}
              </div>
            )}
            <Markdown text={bodyText || ""} mermaid={!streaming} />
            {streaming && <span className="caret" />}
            {artifacts.map((a, i) => <FileArtifact key={i} art={a} />)}
            {onDownloadFile && m.files && m.files.length > 0 && (
              <div className="msg-files">
                {m.files.map((f, i) => <AgentFileCard key={i} file={f} onDownload={onDownloadFile} />)}
              </div>
            )}
          </>
        )}
      {/* Compaction summarises the older turns away, so it necessarily loses
          detail. Shown on the turn it happened in, live and afterwards, so a
          session that did this is distinguishable from one that did not. */}
      {/* Which app this turn was told about. The pointer to it lives in an
          injected prompt line the reader never sees, so an agent talking about
          an app nobody mentioned looked like a hallucination. */}
      {!isUser && m.appContext && (
        <div className="stopped-note">▦ {tb("msg.appContext", { name: m.appContext.name })}</div>
      )}
      {!isUser && m.compacting && streaming && <div className="stopped-note">⟳ {t("msg.compacting")}</div>}
      {!isUser && Array.isArray(m.compactions) && m.compactions.length > 0 && (
        <div className="stopped-note">⟳ {t("msg.compacted", { n: m.compactions.length })}</div>
      )}
      {!isUser && m.stopped && !streaming && <div className="stopped-note">⏹ {t("msg.stoppedNote")}</div>}
      {!isUser && m.truncated && !m.stopped && !streaming && (
        <div className="stopped-note">✂ {m.truncated.outputTokens
          ? t("msg.truncated", { n: L.fmtNum(m.truncated.outputTokens) })
          : t("msg.truncatedPlain")}</div>
      )}
      {!isUser && m.queuedPos != null && streaming && <div className="stopped-note">⏳ {t("msg.queuedNote", { n: m.queuedPos })}</div>}
      {!streaming && !editing && (
        <div className="msg-actions">
          {m.content && <button className="act-btn" onClick={copy}>{copied ? "Copied" : "Copy"}</button>}
          {isUser && onEdit && <button className="act-btn" onClick={startEdit} disabled={busy}>{t("common.edit")}</button>}
          {!isUser && isLast && onRegenerate && m.content && <button className="act-btn" onClick={onRegenerate} disabled={busy}>{t("msg.regenerate")}</button>}
        </div>
      )}
    </div>
  );
}

/* ---------- model selector ---------- */
/* One dropdown, shared by every picker in the app: chat's provider, chat's
   model, and an agent's pair of the same two. Everything that differs between
   them is passed in, so they cannot drift apart again. */
function PickerMenu({ cap, label, title, options, value, onPick, onOpen, children }) {
  const [open, setOpen] = useState(false);
  const ref = useRef(null);
  useEffect(() => {
    const h = e => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
    document.addEventListener("mousedown", h);
    return () => document.removeEventListener("mousedown", h);
  }, []);
  /* Opening the menu is the moment the answer matters, and the only moment it
     is worth asking for: residency changes while you sit there. */
  const toggle = () => { const next = !open; setOpen(next); if (next && onOpen) onOpen(); };
  return (
    <div className="model-sel" ref={ref}>
      <button className="model-btn" onClick={toggle} title={title}>
        {children || <span className="sq" />}
        <span className="sel-label">{label}</span>
        <span className="chev">▼</span>
      </button>
      {open && (
        <div className="model-menu">
          <div className="menu-cap">{cap}</div>
          {options.map(o => (
            <button key={String(o.id)} className={"model-opt " + (o.id === value ? "sel" : "")}
                    onClick={() => { onPick(o.id); setOpen(false); }}>
              <span className="sq" />
              <span className="label">
                {o.label}
                {/* Loaded, and therefore answers immediately. Only ever shown
                    when the provider actually told us; a model with no badge
                    means "unknown", never "cold". */}
                {o.loaded && <span className="model-warm" title={t("model.loadedHint")}>{t("model.loaded")}</span>}
                {o.desc ? <span className="desc">{o.desc}</span> : null}
              </span>
              {o.id === value && <span className="check">✓</span>}
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

/* How full the context window is, as of the last completed turn. The same
   budget pi compacts against, so watching it climb explains the compaction
   note when it arrives rather than surprising the reader with it. */
function ContextGauge({ used, window: win }) {
  if (!used || !win) return null;
  const pct = Math.min(100, Math.round((used / win) * 100));
  const k = n => (n >= 1000 ? Math.round(n / 1000) + "k" : String(n));
  return (
    <span className={"ctx-gauge" + (pct >= 85 ? " hot" : pct >= 60 ? " warm" : "")}
          title={t("ctx.hint", { used: used.toLocaleString(), window: win.toLocaleString() })}>
      <span className="ctx-bar"><span className="ctx-fill" style={{ width: pct + "%" }} /></span>
      {pct}% · {k(used)}/{k(win)}
    </span>
  );
}

/* An agent picks its engine exactly the way a chat conversation picks one:
   WHERE the turn runs, then WHICH model runs there. It used to be a single
   list of backend presets, so an agent could only ever run each provider's one
   default model while chat could choose freely. Same two controls now, over
   the same live catalog. */
function AgentEngineSelect({ agent, onPick }) {
  const [list, setList] = useState(null);
  const [models, setModels] = useState([]);
  useEffect(() => { L.AgentsAPI.backends().then(setList).catch(() => setList(null)); }, []);
  const opts = (list && list.options) || [];
  const cur = opts.find(o => o.key === agent.backend);
  const active = cur || opts.find(o => o.key === (list && list.default));
  useEffect(() => {
    let gone = false;
    setModels([]);
    if (!active || !active.base) return;
    L.loadModelsForBase(active.base).then(m => { if (!gone) setModels(m); }).catch(() => {});
    return () => { gone = true; };
  }, [active && active.base]);

  const defaultLabel = (list && (list.defaultLabel || list.default)) || "";
  const providerOptions = [
    { id: "", label: t("agent.engineDefault", { name: defaultLabel }) },
    ...opts.map(o => ({ id: o.key, label: o.label, desc: o.model })),
  ];
  /* Following the backend's own default is a choice, and the one every agent
     starts on, so it is an entry rather than an absence. */
  const [loaded, setLoaded] = useState(null);
  useEffect(() => { setLoaded(null); }, [active && active.base]);
  const refreshLoaded = useCallback(() => {
    if (!active || !active.base) return;
    L.loadedModels(active.base).then(setLoaded).catch(() => setLoaded(null));
  }, [active && active.base]);
  const isLoaded = id => !!(loaded && loaded.some(l => L.sameModel(l.id, id)));
  const modelOptions = [
    { id: "", label: t("agent.modelDefault"), desc: active ? active.model : "",
      loaded: !!(active && isLoaded(active.model)) },
    ...models.map(m => ({ id: m.id, label: m.label, desc: m.desc, loaded: isLoaded(m.id) })),
  ];
  const modelLabel = agent.modelPin
    ? ((models.find(m => m.id === agent.modelPin) || { label: agent.modelPin }).label)
    : t("agent.modelDefault");

  return (
    <>
      <PickerMenu cap={t("agent.engine")} title={t("agent.engineHint")}
                  label={cur ? cur.label : t("agent.engineDefault", { name: defaultLabel })}
                  options={providerOptions} value={agent.backend || ""}
                  onPick={k => onPick({ backend: k || null })} />
      {models.length > 1 && (
        <PickerMenu cap={t("topbar.model")} title={t("agent.modelHint")} onOpen={refreshLoaded}
                    label={modelLabel} options={modelOptions} value={agent.modelPin || ""}
                    onPick={m => onPick({ model: m || null })} />
      )}
    </>
  );
}

function ModelSelect({ model, setModel, models, base }) {
  const list = models || L.MODELS;
  const cur = list.find(m => m.id === model) || list[0] || { label: model };
  /* Which of these the provider is holding in memory. Asked when the menu is
     opened, because that is when it is read and when it is current. null means
     the provider does not report it, and then nothing is marked at all. */
  const [loaded, setLoaded] = useState(null);
  const refresh = useCallback(() => {
    if (!base) return;
    L.loadedModels(base).then(setLoaded).catch(() => setLoaded(null));
  }, [base]);
  useEffect(() => { setLoaded(null); }, [base]);
  const isLoaded = id => !!(loaded && loaded.some(l => L.sameModel(l.id, id)));
  return (
    <PickerMenu cap={t("topbar.model")} label={cur.label} value={model} onOpen={refresh}
                options={list.map(m => ({ id: m.id, label: m.label, desc: m.desc, loaded: isLoaded(m.id) }))}
                onPick={setModel} />
  );
}

/* ---------- provider selector ---------- */
function ProviderSelect({ provider, setProvider, live }) {
  const [open, setOpen] = useState(false);
  const ref = useRef(null);
  const list = L.providersList();
  const selName = provider || (list[0] && list[0].name);
  useEffect(() => {
    const h = e => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
    document.addEventListener("mousedown", h);
    return () => document.removeEventListener("mousedown", h);
  }, []);
  return (
    <div className="model-sel prov-sel" ref={ref}>
      <button className="model-btn" onClick={() => setOpen(!open)} title={t("topbar.provider")}>
        <span className={"prov-dot " + (live && live === selName ? "on" : "")} />
        <span className="sel-label">{L.providerLabel(selName)}</span>
        <span className="chev">▼</span>
      </button>
      {open && (
        <div className="model-menu">
          <div className="menu-cap">{t("topbar.provider")}</div>
          {list.map(p => (
            <button key={p.name} className={"model-opt " + (p.name === selName ? "sel" : "")}
                    onClick={() => { setProvider(p.name); setOpen(false); }}>
              <span className={"prov-dot " + (live && live === p.name ? "on" : "")} />
              <span className="label">
                {L.providerLabel(p.name)}
                <span className="desc">{p.note || (p.base || "").replace(/^https?:\/\//, "")}</span>
              </span>
              {p.name === selName && <span className="check">✓</span>}
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

/* ---------- empty state ---------- */
const suggestions = () => [
  { k: "Explain", t: t("empty.s1") },
  { k: "Draft",   t: t("empty.s2") },
  { k: "Code",    t: t("empty.s3") },
  { k: "Compare", t: t("empty.s4") },
];
function Empty({ onPick }) {
  return (
    <div className="empty">
      <div className="empty-mark" />
      <h1><BrandName /></h1>
      <p className="sub">{t("empty.blurb")}</p>
      <div className="eyebrow">{t("empty.tryOne")}</div>
      <div className="prompts">
        {suggestions().map((s, i) => (
          <button key={i} className="prompt" onClick={() => onPick(s.t)}>
            <span className="p-k">{s.k}</span>
            <span className="p-t">{s.t}</span>
          </button>
        ))}
      </div>
    </div>
  );
}

/* ---------- composer ---------- */
const TEXT_RE = /^(text\/|application\/(json|xml|javascript|x-sh|x-yaml|sql))|\.(md|markdown|csv|tsv|json|js|jsx|ts|tsx|py|rb|go|rs|java|c|cpp|h|css|html|xml|yml|yaml|toml|ini|sh|sql|txt|log)$/i;
function isTextFile(f) { return TEXT_RE.test(f.type) || TEXT_RE.test(f.name); }
/* Drop a file anywhere on the window, not just on the composer strip — that
   strip is a small target, and everyone aims at the conversation.

   The overlay does not know which composer is mounted, so it announces the
   files as an event and whichever composer is listening claims them. The event
   is cancelable and the claimer calls preventDefault, so if two composers are
   ever mounted at once the file is still only added once.

   A drop ON a composer is handled by the composer's own onDrop, and the same
   native event then bubbles up to this window listener. The composer has
   already called preventDefault by then, so a prevented drop is left alone:
   re-announcing it added every file dropped on the composer twice, with two
   uploads and two conversions. */
function WindowDrop() {
  const [over, setOver] = useState(false);
  const depth = useRef(0);
  useEffect(() => {
    const hasFiles = e => Array.from((e.dataTransfer && e.dataTransfer.types) || []).includes("Files");
    const onEnter = e => { if (!hasFiles(e)) return; e.preventDefault(); depth.current++; setOver(true); };
    const onOver  = e => { if (!hasFiles(e)) return; e.preventDefault(); };
    const onLeave = e => { if (!hasFiles(e)) return; depth.current--; if (depth.current <= 0) { depth.current = 0; setOver(false); } };
    const onDrop  = e => {
      if (!hasFiles(e)) return;
      depth.current = 0; setOver(false);
      if (e.defaultPrevented) return;        /* a composer already took it */
      e.preventDefault();
      const files = e.dataTransfer && e.dataTransfer.files;
      if (files && files.length) window.dispatchEvent(new CustomEvent("indie:files", { detail: files, cancelable: true }));
    };
    window.addEventListener("dragenter", onEnter);
    window.addEventListener("dragover", onOver);
    window.addEventListener("dragleave", onLeave);
    window.addEventListener("drop", onDrop);
    return () => {
      window.removeEventListener("dragenter", onEnter);
      window.removeEventListener("dragover", onOver);
      window.removeEventListener("dragleave", onLeave);
      window.removeEventListener("drop", onDrop);
    };
  }, []);
  if (!over) return null;
  return (
    <div className="drop-veil">
      <div className="drop-veil-box"><ClipIcon /> {t("attach.dropAnywhere")}</div>
    </div>
  );
}

/* Raster images go to the model's VISION path (image_url), NOT MarkItDown. */
function isImageFile(f) { return /^image\/(png|jpe?g|gif|webp|bmp)$/i.test(f.type) || /\.(png|jpe?g|gif|webp|bmp)$/i.test(f.name); }
function fmtSize(n) {
  return n < 1024 ? n + "B" : n < 1048576 ? (n / 1024).toFixed(0) + "K" : (n / 1048576).toFixed(1) + "M";
}

/* Read an image as a data URL, downscaling large ones (max 1568px, JPEG) to
   keep the request/payload small for vision models. */
function fileToImageDataUrl(file) {
  return new Promise((resolve, reject) => {
    const r = new FileReader();
    r.onerror = () => reject(new Error("read failed"));
    r.onload = () => {
      const raw = String(r.result);
      const img = new Image();
      img.onerror = () => resolve(raw);
      img.onload = () => {
        const MAX = 1568;
        let w = img.naturalWidth, h = img.naturalHeight;
        if (!w || !h || Math.max(w, h) <= MAX) { resolve(raw); return; }
        const s = MAX / Math.max(w, h); w = Math.round(w * s); h = Math.round(h * s);
        try {
          const c = document.createElement("canvas"); c.width = w; c.height = h;
          c.getContext("2d").drawImage(img, 0, 0, w, h);
          resolve(c.toDataURL("image/jpeg", 0.85));
        } catch { resolve(raw); }
      };
      img.src = raw;
    };
    r.readAsDataURL(file);
  });
}

/* ---------- Tools toggle + per-server dropdown ---------- */
function ToolsControl({ on, onToggle }) {
  const [open, setOpen] = useState(false);
  const [servers, setServers] = useState(() => L.getMcpServers());
  const ref = useRef(null);
  useEffect(() => { L.loadMcpServers().then(() => setServers([...L.getMcpServers()])); }, []);
  useEffect(() => {
    if (!open) return;
    const h = e => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
    document.addEventListener("mousedown", h);
    return () => document.removeEventListener("mousedown", h);
  }, [open]);
  const toggleServer = (id, val) => { L.setMcpServerEnabled(id, val); setServers([...L.getMcpServers()]); };
  const hasServers = servers.length > 0;
  return (
    <div className="tool-toggle-wrap" ref={ref}>
      <button className={"tool-toggle " + (on ? "on" : "")} onClick={onToggle}
              title={t("tools.mcpHint")}>
        <ToolsIcon /> Tools
      </button>
      {hasServers && (
        <button className={"tt-caret-btn " + (on ? "on" : "")} title={t("tools.chooseServers")}
                onClick={() => setOpen(o => !o)}>▾</button>
      )}
      {open && hasServers && (
        <div className="mcp-menu">
          <div className="mcp-menu-head">{t("tools.mcpServers")}</div>
          {servers.map(s => (
            <label key={s.id} className="mcp-menu-row">
              <button className={"mcp-switch " + (s.enabled === false ? "" : "on")}
                      onClick={e => { e.preventDefault(); toggleServer(s.id, s.enabled === false); }}><span /></button>
              <span className="mcp-menu-name">{s.name}</span>
            </label>
          ))}
        </div>
      )}
    </div>
  );
}

function Composer({ onSend, onStop, busy, presetText, clearPreset, mcpAvailable }) {
  const [text,   setText]   = useState("");
  const [focus,  setFocus]  = useState(false);
  const [drag,   setDrag]   = useState(false);
  const [files,  setFiles]  = useState([]);
  const [search, setSearch] = useState(false);
  const [recording,    setRecording]    = useState(false);
  const [transcribing, setTranscribing] = useState(false);
  const [voiceErr,     setVoiceErr]      = useState(null);
  const ta        = useRef(null);
  const fileInput = useRef(null);
  const dragDepth = useRef(0);
  const rec       = useRef(null);   /* { ctx, processor, source, stream, chunks } */

  const [priv,   setPriv]   = useState(false);
  const [mcp,    setMcp]    = useState(false);
  const [mkfile, setMkfile] = useState(false);
  const skillsAvail      = L.enabledSkills().length > 0;
  /* "Tools" covers MCP tools AND Skills — available if either exists */
  const mcpOn            = (mcpAvailable && L.toolEnabled("mcp")) || skillsAvail;
  const searchAvailable  = L.toolEnabled("search");
  const filesAvailable   = L.toolEnabled("files");
  const privacyAvailable = L.toolEnabled("privacy");
  const filegenAvailable = L.toolEnabled("filegen");
  const voiceAvailable   = typeof navigator !== "undefined" && navigator.mediaDevices && window.AudioContext;

  useEffect(() => {
    if (presetText) {
      setText(presetText);
      clearPreset();
      requestAnimationFrame(() => ta.current && ta.current.focus());
    }
  }, [presetText]);

  useEffect(() => {
    const el = ta.current; if (!el) return;
    el.style.height = "auto";
    el.style.height = Math.min(el.scrollHeight, 220) + "px";
  }, [text]);

  const MAX_UPLOAD = 32 * 1024 * 1024; /* must match server express.raw limit */
  const patchFile = (id, patch) => setFiles(fs => fs.map(x => x.id === id ? { ...x, ...patch } : x));

  /* EVERY file goes through the same server-side converter, images included:
     a photo of a document is a document, and OCR turns it into text the model
     can quote instead of squinting at. An image additionally keeps its data URL
     so the model still SEES it, and a PDF that turned out to be a scan comes
     back with page images when even OCR found nothing. Text and pictures are
     not either/or here — a file can contribute both. */
  const addFiles = list => {
    Array.from(list).forEach(f => {
      const id = Math.random().toString(36).slice(2);
      const image = isImageFile(f);
      setFiles(fs => [...fs, { id, name: f.name, size: f.size, type: f.type, isImage: image, converting: true }]);
      if (f.size > MAX_UPLOAD) {
        patchFile(id, { converting: false, error: "too large (max 32 MB)" });
        return;
      }
      /* The picture itself, for vision. Read locally: it is already in the
         browser, and this keeps working even if conversion fails. */
      if (image) {
        fileToImageDataUrl(f)
          .then(url => patchFile(id, { dataUrl: url }))
          .catch(() => {});
      }
      L.convertFile(f)
        .then(r => patchFile(id, {
          converting: false, text: r.markdown, chars: r.chars, truncated: r.truncated,
          ocr: r.ocr, pages: r.pages || [], note: r.note,
        }))
        .catch(e => patchFile(id, {
          converting: false,
          /* An image we could not convert is still perfectly usable: the model
             looks at it. Only a non-image is a dead end worth reporting. */
          error: image ? undefined : ((e && e.message) || "conversion failed"),
        }));
    });
  };

  /* ---- voice input: capture mic → WAV → MarkItDown transcription ---- */
  const startRecording = async () => {
    setVoiceErr(null);
    try {
      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
      const ctx    = new (window.AudioContext || window.webkitAudioContext)();
      const source = ctx.createScriptProcessor ? ctx.createScriptProcessor(4096, 1, 1) : ctx.createScriptProcessor(4096, 1, 1);
      const mic    = ctx.createMediaStreamSource(stream);
      const chunks = [];
      source.onaudioprocess = e => { chunks.push(new Float32Array(e.inputBuffer.getChannelData(0))); };
      mic.connect(source); source.connect(ctx.destination);
      rec.current = { ctx, processor: source, source: mic, stream, chunks };
      setRecording(true);
    } catch (e) {
      setVoiceErr(e && e.name === "NotAllowedError" ? t("voice.denied") : t("voice.startFailed"));
    }
  };

  const stopRecording = async () => {
    const r = rec.current; rec.current = null;
    setRecording(false);
    if (!r) return;
    try { r.processor.disconnect(); r.source.disconnect(); } catch {}
    r.stream.getTracks().forEach(t => t.stop());
    const sampleRate = r.ctx.sampleRate;
    try { await r.ctx.close(); } catch {}

    const totalSamples = r.chunks.reduce((n, c) => n + c.length, 0);
    if (totalSamples < sampleRate * 0.3) { setVoiceErr(t("voice.tooShort")); return; } /* < 0.3s */

    setTranscribing(true);
    try {
      const wav  = L.pcmToWavBlob(r.chunks, sampleRate);
      const file = new File([wav], "voice.wav", { type: "audio/wav" });
      const res  = await L.convertFile(file);
      const said = L.transcriptText(res.markdown);
      if (said) {
        setText(t => (t ? t.replace(/\s*$/, " ") : "") + said);
        requestAnimationFrame(() => ta.current && ta.current.focus());
      } else {
        setVoiceErr(t("voice.noSpeech"));
      }
    } catch (e) {
      setVoiceErr((e && e.message) || t("voice.failed"));
    } finally {
      setTranscribing(false);
    }
  };

  const toggleMic = () => { if (transcribing) return; recording ? stopRecording() : startRecording(); };

  /* Files dropped anywhere on the window (see WindowDrop). No dependency array:
     addFiles closes over current state, so the listener is re-bound each render
     rather than capturing a stale one. Claiming the event stops a second
     mounted composer from adding the same file twice. */
  useEffect(() => {
    const onFiles = e => {
      if (e.defaultPrevented || !e.detail || !e.detail.length) return;
      if (!filesAvailable) return;
      e.preventDefault();
      addFiles(e.detail);
    };
    window.addEventListener("indie:files", onFiles);
    return () => window.removeEventListener("indie:files", onFiles);
  });

  /* One chooser per tap. A hidden input opened twice shows the picker twice,
     which is what a doubled activation looks like on a phone — and touch
     surfaces have more ways to double-fire than a mouse does. Cheap to make
     impossible rather than to keep diagnosing. */
  const pickerBusy = useRef(0);
  const openPicker = () => {
    const now = Date.now();
    if (now - pickerBusy.current < 800) return;
    pickerBusy.current = now;
    if (fileInput.current) fileInput.current.click();
  };

  const onDrop      = e => { e.preventDefault(); dragDepth.current = 0; setDrag(false); if (filesAvailable && e.dataTransfer.files?.length) addFiles(e.dataTransfer.files); };
  const onDragEnter = e => { e.preventDefault(); if (!filesAvailable) return; dragDepth.current++; setDrag(true); };
  const onDragLeave = e => { e.preventDefault(); dragDepth.current--; if (dragDepth.current <= 0) setDrag(false); };
  /* Paste (Ctrl/Cmd+V) an image from the clipboard -> attach it. */
  const onPaste = e => {
    if (!filesAvailable) return;
    const imgs = [...(e.clipboardData?.items || [])]
      .filter(it => it.kind === "file" && it.type.startsWith("image/"))
      .map(it => { const f = it.getAsFile(); if (!f) return null; return f.name ? f : new File([f], "pasted-" + Date.now() + "." + ((f.type.split("/")[1]) || "png"), { type: f.type }); })
      .filter(Boolean);
    if (imgs.length) { e.preventDefault(); addFiles(imgs); }
  };

  const converting = files.some(f => f.converting);
  const submit = () => {
    const t = text.trim();
    if ((!t && !files.length) || busy || converting) return;
    /* forward attachments that are ready: text (MarkItDown) or images (data URL) */
    onSend(t, { files: files.filter(f => f.text || f.dataUrl || (f.pages && f.pages.length)), search: search && searchAvailable, privacy: priv && privacyAvailable, tools: mcp && mcpOn, mcp: mcp && mcpOn, filegen: mkfile && filegenAvailable });
    setText(""); setFiles([]);
  };
  const key = e => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); } };

  return (
    <div className="composer-wrap">
      <div className={"composer " + (focus ? "focus " : "") + (drag ? "drag" : "")}
           onDrop={onDrop} onDragOver={e => e.preventDefault()}
           onDragEnter={onDragEnter} onDragLeave={onDragLeave}>

        {files.length > 0 && (
          <div className="attach-row">
            {files.map(f => (
              <div key={f.id}
                   className={"chip " + (f.error ? "bin " : "") + (f.converting ? "loading" : "")}
                   title={f.error ? f.error : f.converting ? (f.isImage ? t("attach.readingImage") : t("attach.converting"))
                          : [f.name, f.note, f.truncated ? t("attach.truncated").trim() : ""].filter(Boolean).join(" — ")}>
                {f.isImage && f.dataUrl
                  ? <img className="chip-thumb" src={f.dataUrl} alt="" />
                  : <span className="chip-ic" />}
                <span className="chip-name">{f.name}</span>
                <span className="chip-sz">
                  {/* Say what actually happened: silently OCR'ing a scan and
                      still labelling it "image" is why people could not tell
                      whether their document had been read. */}
                  {f.converting ? (f.isImage ? "reading…" : "converting…")
                    : f.error ? "failed"
                    : f.ocr === "tesseract" ? t("attach.ocr")
                    : (f.pages && f.pages.length) ? t("attach.pages", { n: f.pages.length })
                    : f.isImage ? "image"
                    : f.truncated ? "truncated" : fmtSize(f.size)}
                </span>
                <button className="chip-x" onClick={() => setFiles(fs => fs.filter(x => x.id !== f.id))}>✕</button>
              </div>
            ))}
          </div>
        )}

        <div className="composer-row">
          {filesAvailable && (
            <>
              <button type="button" className="attach-btn" title={t("attach.files")} onClick={openPicker}><ClipIcon /></button>
              <input ref={fileInput} type="file" multiple hidden onChange={e => { addFiles(e.target.files); e.target.value = ""; }} />
            </>
          )}
          <textarea
            ref={ta} value={text} rows={1}
            placeholder={drag ? t("attach.drop") : t("composer.placeholder", { brand: L.BRAND.brandName || "indie.chat" })}
            onChange={e => setText(e.target.value)}
            onKeyDown={key}
            onPaste={onPaste}
            onFocus={() => setFocus(true)}
            onBlur={() => setFocus(false)}
          />
          {voiceAvailable && (
            <button className={"mic-btn " + (recording ? "rec " : "") + (transcribing ? "busy" : "")}
                    onClick={toggleMic} disabled={transcribing}
                    title={recording ? t("voice.stopAndTranscribe") : transcribing ? t("voice.transcribing") : t("voice.input")}>
              <MicIcon />
            </button>
          )}
          {busy
            ? <button className="stop" onClick={onStop} title={t("common.stop")}><span className="sq" /></button>
            : <button className="send" onClick={submit} disabled={converting || recording || transcribing || (!text.trim() && !files.length)} title={converting ? t("attach.convertingAll") : t("common.send")}><SendIcon /></button>}
        </div>

        <div className="composer-foot">
          <div className="foot-left">
            {searchAvailable && (
              <button className={"tool-toggle " + (search ? "on" : "")} onClick={() => setSearch(s => !s)} title={t("tools.webSearch")}>
                <SearchIcon /> {t("tools.search")}
              </button>
            )}
            {privacyAvailable && (
              <button className={"tool-toggle " + (priv ? "on" : "")} onClick={() => setPriv(p => !p)}
                      title={t("tools.privacyHint")}>
                <ShieldIcon /> {t("tools.private")} <span className="tt-sub">(OPF)</span>
              </button>
            )}
            {mcpOn && <ToolsControl on={mcp} onToggle={() => setMcp(m => !m)} />}
            {filegenAvailable && (
              <button className={"tool-toggle " + (mkfile ? "on" : "")} onClick={() => setMkfile(m => !m)}
                      title={t("tools.filegenHint")}>
                <FileGenIcon /> {t("tools.makeFile")}
              </button>
            )}
            {recording
              ? <span className="voice-status rec"><span className="rec-dot" /> {t("voice.recording")}</span>
              : transcribing
              ? <span className="voice-status"><span className="rec-dot busy" /> {t("voice.transcribing")}</span>
              : voiceErr
              ? <span className="voice-status err">{voiceErr}</span>
              : <span className="hint">{tb("composer.hint")}</span>}
          </div>
          {text.length > 0 && <span className="count">{text.length}</span>}
        </div>
      </div>
    </div>
  );
}

/* ---------- draggable pane divider ----------
   Reports the pointer position while dragging; the owner turns that into a
   size and clamps it. Pointer capture is what makes the drag survive the
   cursor outrunning a 7px handle — and what lets it keep tracking over the
   app iframe, which otherwise swallows the events. Double-click resets,
   arrow keys nudge for anyone not using a mouse. */
function Splitter({ axis, onDrag, onNudge, onReset, label, className }) {
  const [on, setOn] = useState(false);
  /* Latest handler without re-subscribing the listeners on every parent render. */
  const dragRef = useRef(onDrag);
  dragRef.current = onDrag;
  /* Listen on WINDOW rather than the handle: the pointer routinely leaves a 7px
     target mid-drag, and next to an app the neighbour is a sandboxed iframe
     that would otherwise swallow the events (body.resizing also disables its
     pointer events while a drag is live). */
  useEffect(() => {
    if (!on) return;
    const move = e => dragRef.current(e.clientX, e.clientY);
    const up   = () => setOn(false);
    window.addEventListener("pointermove", move);
    window.addEventListener("pointerup", up);
    window.addEventListener("pointercancel", up);
    document.body.classList.add("resizing");
    if (axis === "h") document.body.classList.add("row");
    return () => {
      window.removeEventListener("pointermove", move);
      window.removeEventListener("pointerup", up);
      window.removeEventListener("pointercancel", up);
      document.body.classList.remove("resizing", "row");
    };
  }, [on, axis]);
  return (
    <div className={"splitter " + axis + (on ? " on" : "") + (className ? " " + className : "")}
         role="separator" aria-orientation={axis === "v" ? "vertical" : "horizontal"}
         aria-label={label} tabIndex={0} title={label + " — drag to resize, double-click to reset"}
         onDoubleClick={onReset}
         onKeyDown={e => {
           const k = { ArrowLeft: -16, ArrowRight: 16, ArrowUp: -16, ArrowDown: 16 }[e.key];
           if (k == null) return;
           e.preventDefault(); onNudge(k);
         }}
         onPointerDown={e => { if (e.button === 0) { e.preventDefault(); setOn(true); } }}
    />
  );
}

/* Is the box busy, and is it somebody else's turn?
   One model server usually sits behind a self-hosted deployment, so another
   person's run is why yours waits — which used to be invisible, leaving "it is
   slow today" as the only available explanation. Counts only, never who or
   what: a busy light does not need to say what anyone is working on. */
function BoxBusy({ box }) {
  if (!box || !box.total) return null;
  const others = box.others || 0;
  const label = others
    ? t("box.others", { n: others })
    : t("box.mine", { n: box.mine });
  const title = (others ? t("box.othersHint") : t("box.mineHint"))
    + (box.queued ? " " + t("box.queuedHint", { n: box.queued }) : "");
  return (
    <span className={"box-busy" + (others ? " others" : "")} title={title}>
      <span className="bb-dot" />
      {label}{box.queued ? " · " + t("box.queued", { n: box.queued }) : ""}
    </span>
  );
}

/* ---------- sidebar ---------- */
function Sidebar({ convs, agents, activeId, activeKind, onSelect, onNew, onNewAgent, onDelete, onDeleteAgent, apps, activeAppId, onSelectApp, onNewApp, onDeleteApp, user, onLogout, onRoutines, activity, sideTab, onSideTab, panes, onPane }) {
  const initials = user ? user.name.split(" ").map(w => w[0]).join("").slice(0, 2).toUpperCase() : "?";
  const isAdmin  = user && ["owner", "admin"].includes(user.role);
  const inst     = useInstall();
  const [menuOpen, setMenuOpen] = useState(false);
  const appsRef  = useRef(null);

  /* Pane drags. The sidebar's own width is measured from the window edge; the
     apps list from its own top, so the number stays right however much sits
     above it. */
  const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
  const sideW = () => clamp(Math.round(panes.side || 264), 210, Math.min(460, window.innerWidth - 360));
  const setSide = v => onPane("side", clamp(Math.round(v), 210, Math.min(460, window.innerWidth - 360)));
  const setAppsH = y => {
    const top = appsRef.current ? appsRef.current.getBoundingClientRect().top : 0;
    onPane("apps", clamp(Math.round(y - top), 56, Math.max(96, window.innerHeight - top - 200)));
  };

  /* FOREGROUND: interactive chats + agents. BACKGROUND: agents that work
     unattended (long runs, schedules) — richer status rows. */
  const bgAgents = (agents || []).filter(a => a.background);
  const entries = [
    ...(convs || []).map(c => ({ kind: "chat",  id: c.id, title: c.title || t("chat.untitled"), updated: c.updated || 0 })),
    ...(agents || []).filter(a => !a.background).map(a => ({ kind: "agent", id: a.id, title: a.name || "Agent", updated: a.updated || 0, scope: a.scope, wiki: a.wiki })),
  ].sort((x, y) => (y.updated || 0) - (x.updated || 0));
  /* The shared wiki's sole writer (the Librarian) is infrastructure: the server
     refuses to delete it, so don't offer the affordance. */
  const isLibrarian = a => a.scope === "global" && a.wiki === "write";

  const runs = (activity && activity.runs) || [];
  const schedules = (activity && activity.schedules) || [];
  const bgIds = new Set(bgAgents.map(a => a.id));
  const liveCount   = runs.filter(r => (r.status === "running" || r.status === "queued") && bgIds.has(r.agentId)).length;
  const orphanCount = runs.filter(r => r.status === "orphaned" && bgIds.has(r.agentId)).length;
  const mins = ms => { const m = Math.max(1, Math.round(ms / 60000)); return m < 60 ? m + "m" : Math.round(m / 60 * 10) / 10 + "h"; };
  const inMs = ms => { const m = Math.round(ms / 60000); return m < 60 ? "in " + Math.max(1, m) + "m" : m < 60 * 48 ? "in " + Math.round(m / 60) + "h" : "in " + Math.round(m / 1440) + "d"; };
  const services = (activity && activity.services) || [];
  const bgStatus = a => {
    const mine = runs.filter(r => r.agentId === a.id);
    const live = mine.find(r => r.status === "running");
    const queued = mine.find(r => r.status === "queued");
    const orphan = mine.find(r => r.status === "orphaned");
    const sched = schedules.filter(s => s.agentId === a.id && s.enabled && s.nextRun).sort((x, y) => x.nextRun - y.nextRun)[0];
    const upSvc = services.filter(s => s.agentId === a.id && s.status === "running").length;
    const schedTxt = (sched ? " · ⏰ " + inMs(sched.nextRun - Date.now()) : "") + (upSvc ? " · ▲ " + upSvc + " svc" : "");
    if (live)   return { cls: "live",   text: "● running · " + mins(Date.now() - (live.started || live.created)) + schedTxt };
    if (queued) return { cls: "queued", text: "● queued" + schedTxt };
    if (orphan) return { cls: "orphan", text: t("run.interrupted") + schedTxt };
    const done = mine.filter(r => r.finished).sort((x, y) => y.finished - x.finished)[0];
    if (done) return { cls: done.status === "error" ? "orphan" : "idle", text: (done.status === "error" ? "✗ failed · " : "✓ done · ") + L.relTime(done.finished) + schedTxt };
    return { cls: "idle", text: (sched ? "⏰ " + inMs(sched.nextRun - Date.now()) : "idle") };
  };

  return (
    <aside className="sidebar">
      <div className="brand">
        <div className="logo" />
        <div>
          <div className="brand-name"><BrandName /></div>
          <div className="brand-tag">{L.BRAND.tagline}</div>
        </div>
      </div>
      <div className="new-split">
        <button className="new-chat" onClick={onNewAgent}>{t("sidebar.newAgent")} <span className="plus"><BotIcon /></span></button>
        <button className="new-caret" title={t("sidebar.newMenu")} onClick={() => setMenuOpen(o => !o)}>▾</button>
        {menuOpen && (
          <>
            <div className="new-scrim" onClick={() => setMenuOpen(false)} />
            <div className="new-menu">
              <button onClick={() => { setMenuOpen(false); onNewAgent(); }}><BotIcon /> {t("sidebar.newAgent")} <span className="beta">beta</span></button>
              <button onClick={() => { setMenuOpen(false); onNew(); }}><PlusIcon /> {t("sidebar.newChat")}</button>
            </div>
          </>
        )}
      </div>
      <div className="conv-head mono apps-head">
        Apps
        <button className="apps-add" title={t("sidebar.newApp")} onClick={onNewApp}>+</button>
      </div>
      {(apps || []).length > 0 && (
        <div className="apps-list" ref={appsRef}>
          {apps.map(a => (
            <div key={a.id}
                 className={"conv app-tab " + (a.id === activeAppId ? "active" : "")}
                 onClick={() => onSelectApp(a.id)}>
              <div className="conv-title">
                <span className="app-ic">{a.icon || "▦"}</span>
                {a.name}
                {a.scope === "global" && <span className="app-scope" title={t("app.globalHint")}>G</span>}
              </div>
              {(a.scope !== "global" || isAdmin) && (
                <button className="conv-del" title={a.scope === "global" ? t("app.deleteGlobal") : t("app.delete")}
                        onClick={ev => { ev.stopPropagation(); onDeleteApp(a.id); }}>✕</button>
              )}
            </div>
          ))}
        </div>
      )}
      {(apps || []).length > 0 && (
        <Splitter axis="h" label={t("pane.appsAgents")}
                  onDrag={(x, y) => setAppsH(y)}
                  onNudge={d => setAppsH((appsRef.current ? appsRef.current.getBoundingClientRect().bottom : 0) + d)}
                  onReset={() => onPane("apps", null)} />
      )}

      <div className="side-tabs">
        <button className={"side-tab " + (sideTab !== "bg" ? "on" : "")} onClick={() => onSideTab("fg")}>{t("sidebar.foreground")}</button>
        <button className={"side-tab " + (sideTab === "bg" ? "on" : "")} onClick={() => onSideTab("bg")}>
          {t("sidebar.background")}
          {liveCount > 0 && <span className="side-tab-badge live">{liveCount}</span>}
          {liveCount === 0 && orphanCount > 0 && <span className="side-tab-badge orphan">{orphanCount}</span>}
        </button>
      </div>
      <div className="conv-list">
        {sideTab !== "bg" ? (<>
          {entries.length === 0 && (
            <div style={{ padding: "10px 12px", fontFamily: "var(--mono)", fontSize: 11, color: "var(--ink-4)" }}>
              {t("sidebar.nothingYet")}
            </div>
          )}
          {entries.map(e => (
            <div key={e.kind + ":" + e.id}
                 className={"conv " + (e.id === activeId && e.kind === activeKind && !activeAppId ? "active" : "")}
                 onClick={() => onSelect(e.id, e.kind)}>
              <div className="conv-title">
                {e.kind === "agent" && <span className="entry-badge"><BotIcon /> AGENT</span>}
                {e.title}
                {e.scope === "global" && <span className="app-scope" title={t("agent.sharedHint")}>G</span>}
              </div>
              <div className="conv-meta">{L.relTime(e.updated)}</div>
              {isLibrarian(e)
                ? <span className="conv-locked" title={t("agent.librarianLocked")}>✎</span>
                : <button className="conv-del" title={t("common.delete")}
                          onClick={ev => { ev.stopPropagation(); (e.kind === "agent" ? onDeleteAgent : onDelete)(e.id); }}>✕</button>}
            </div>
          ))}
        </>) : (<>
          {bgAgents.length === 0 && (
            <div style={{ padding: "12px 14px", fontSize: 12, color: "var(--ink-3)", lineHeight: 1.5 }}>
              {tb("sidebar.bgEmpty", { routines: t("routine.nav"), foreground: t("sidebar.foreground") })}
            </div>
          )}
          {bgAgents.sort((x, y) => (y.updated || 0) - (x.updated || 0)).map(a => {
            const st = bgStatus(a);
            return (
              <div key={a.id}
                   className={"conv bg-row " + (a.id === activeId && activeKind === "agent" && !activeAppId ? "active" : "")}
                   onClick={() => onSelect(a.id, "agent")}>
                <div className="conv-title">
                  <span className="entry-badge"><BotIcon /> AGENT</span>
                  {a.name || "Agent"}
                </div>
                <div className={"bg-status " + st.cls}>{st.text}</div>
                {isLibrarian(a)
                  ? <span className="conv-locked" title={t("agent.librarianLocked")}>✎</span>
                  : <button className="conv-del" title={t("common.delete")}
                            onClick={ev => { ev.stopPropagation(); onDeleteAgent(a.id); }}>✕</button>}
              </div>
            );
          })}
        </>)}
      </div>

      {/* Routines is the one place scheduled work lives, so it is always
          reachable — it used to ride on the admin/installable footer and was
          invisible to an ordinary user. */}
      <div className="sidebar-foot">
        <button className="foot-link" title={t("routine.navHint")} onClick={onRoutines}>
          <ClockIcon /> {t("routine.nav")}
        </button>
        {/* ONE settings page for everyone. The old admin console is its
            Workspace group now, so there is no separate Admin link, and
            Settings no longer hides behind a topbar gear. */}
        <a className="foot-link" href="/settings.html" title={t("nav.settingsHint")}>
          <GearIcon /> {t("nav.settings")}
        </a>
        {inst.available() && (
          <button className="install-link" title={t("pwa.installHint")}
                  onClick={() => { if (inst.canPrompt()) inst.prompt(); else inst.requestShow(); }}>
            <DownloadIcon /> {t("pwa.installApp")}
          </button>
        )}
      </div>

      {user && (
        <div className="user-foot">
          <a className="user-link" href="/settings.html#account" title={t("settings.nav.account")}>
            <span className="user-avatar">{initials}</span>
            <span className="user-info">
              <span className="user-name">{user.name}</span>
              <span className="user-email">{user.email}</span>
            </span>
          </a>
          <button className="logout-btn" onClick={onLogout} title={t("nav.signOut")}>⏻</button>
        </div>
      )}
      <Splitter axis="v" className="side-edge" label={t("pane.sidebar")}
                onDrag={x => setSide(x)}
                onNudge={d => setSide(sideW() + d)}
                onReset={() => onPane("side", null)} />
    </aside>
  );
}

/* ========================================================
   ROOT APP
   ======================================================== */
/* ========================================================
   AGENT VIEW — a persistent coding agent's thread (pi-powered, beta)
   ======================================================== */
function fmtBytes(n) {
  if (n == null) return "";
  if (n < 1024) return n + " B";
  if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
  return (n / (1024 * 1024)).toFixed(1) + " MB";
}

const CanvasIcon = () => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
    <rect x="3" y="4" width="18" height="16" rx="2" /><path d="M14 4v16" />
  </svg>
);

/* Lazy-load the (large) mermaid bundle only when a diagram actually needs it. */
function ensureMermaid() {
  if (window.__mermaidLoad) return window.__mermaidLoad;
  window.__mermaidLoad = new Promise((resolve, reject) => {
    if (window.mermaid) { resolve(window.mermaid); return; }
    const s = document.createElement("script");
    s.src = "vendor/mermaid.min.js";
    s.onload = () => { try { window.mermaid.initialize({ startOnLoad: false, securityLevel: "strict", theme: "neutral" }); } catch {} resolve(window.mermaid); };
    s.onerror = () => reject(new Error("mermaid failed to load"));
    document.head.appendChild(s);
  });
  return window.__mermaidLoad;
}

/* Upgrade any ```mermaid code blocks inside `el` to rendered SVG diagrams.
   Shared by the chat/agent transcript (inline) and the canvas panel. */
async function renderMermaidIn(el) {
  if (!el) return;
  const blocks = [...el.querySelectorAll("code.language-mermaid")].filter(c => !c.dataset.mmdDone);
  if (!blocks.length) return;
  let mermaid;
  try { mermaid = await ensureMermaid(); } catch { return; }
  for (const code of blocks) {
    code.dataset.mmdDone = "1";
    const src = code.textContent || "";
    const host = code.closest("pre") || code;
    try {
      const { svg } = await mermaid.render("mmd-" + Math.random().toString(36).slice(2), src);
      const fig = document.createElement("div"); fig.className = "mermaid-fig"; fig.innerHTML = svg;
      host.replaceWith(fig);
    } catch {}
  }
}

/* The visual canvas: renders one payload (markdown+mermaid / html / url / pdf / xlsx). */
function AgentCanvas({ agentId, payload }) {
  const mdRef = useRef(null);
  const [ld, setLd] = useState({ status: "idle" });   /* async load state for pdf/xlsx */
  const kind = payload.kind;

  /* Markdown: render + upgrade ```mermaid blocks to SVG, then highlight the rest. */
  useEffect(() => {
    if (kind !== "markdown" && kind !== "xlsx") return;
    let cancelled = false;
    (async () => {
      let md = payload.content || "";
      if (kind === "xlsx") {
        setLd({ status: "loading" });
        try { md = await L.AgentsAPI.previewMarkdown(agentId, payload.path); }
        catch (e) { if (!cancelled) setLd({ status: "error", error: e.message }); return; }
        if (cancelled) return;
        setLd({ status: "ready" });
      }
      const el = mdRef.current; if (!el) return;
      el.innerHTML = L.renderMarkdown(md);
      if (L.decorateCode) try { L.decorateCode(el); } catch {}
      if (!cancelled) renderMermaidIn(el);
    })();
    return () => { cancelled = true; };
  }, [kind, payload.content, payload.path, agentId]);

  /* PDF: fetch the workspace file (auth) → object URL → native viewer. */
  useEffect(() => {
    if (kind !== "pdf") return;
    let cancelled = false, url = null;
    setLd({ status: "loading" });
    L.AgentsAPI.fileBlob(agentId, payload.path)
      .then(b => {
        if (cancelled) return;
        /* Re-type as application/pdf: the file endpoint serves octet-stream, and
           an octet-stream blob makes the iframe DOWNLOAD instead of rendering. */
        const pdf = b.type === "application/pdf" ? b : new Blob([b], { type: "application/pdf" });
        url = URL.createObjectURL(pdf); setLd({ status: "ready", url });
      })
      .catch(e => { if (!cancelled) setLd({ status: "error", error: e.message }); });
    return () => { cancelled = true; if (url) setTimeout(() => URL.revokeObjectURL(url), 4000); };
  }, [kind, payload.path, agentId]);

  if (kind === "html")
    /* allow-forms + allow-modals: a form submit or confirm()/alert() inside
       agent-built HTML fails SILENTLY without them (no page-visible error).
       Scripts are already allowed, so neither flag adds reachable surface;
       still no allow-same-origin — the document keeps its opaque origin. */
    return <iframe className="canvas-frame" sandbox="allow-scripts allow-popups allow-forms allow-modals" srcDoc={payload.content || ""} title={t("canvas.htmlPreview")} />;
  if (kind === "url")
    return <iframe className="canvas-frame" sandbox="allow-scripts allow-popups allow-forms allow-same-origin" src={payload.content || ""} title={t("canvas.sitePreview")} />;
  if (kind === "pdf") {
    if (ld.status === "error") return <div className="canvas-status err">Couldn't load PDF: {ld.error}</div>;
    if (ld.status !== "ready") return <div className="canvas-status">{t("canvas.loadingPdf")}</div>;
    return <iframe className="canvas-frame" src={ld.url} title={t("canvas.pdfPreview")} />;
  }
  if (kind === "xlsx" && ld.status === "error") return <div className="canvas-status err">Couldn't load: {ld.error}</div>;
  /* markdown + xlsx(as markdown table) render into this element */
  return <div className={"canvas-md md" + (kind === "xlsx" ? " canvas-table" : "")} ref={mdRef} />;
}

/* Jump, don't glide: .scroll is scroll-behavior:smooth, which is right for the
   inch-by-inch autoscroll of a streaming answer and wrong for "take me to the
   end of a thousand-line thread". Suspending the property beats
   behavior:"instant", which older Safari rejects outright. */
function jumpNow(el) {
  const prev = el.style.scrollBehavior;
  el.style.scrollBehavior = "auto";
  el.scrollTop = el.scrollHeight;
  el.style.scrollBehavior = prev;
}

/* A transcript that follows new content, plus a way back to the end.

   Driven by a ResizeObserver rather than by a render signature, because the
   height of a thread is not final when React commits it: markdown, syntax
   highlighting, KaTeX and images all settle afterwards, and a jump measured
   too early lands in the middle of a long conversation. Watching the box
   catches every one of those, and streamed tokens for free.

   `id` is the thread being shown: a new one starts pinned to its newest turn.
   Chat passes in the App's own refs (its streaming callbacks poke stickRef
   directly); the agent view lets the hook own them. */
function useTranscriptScroll(id, extRef, extStick) {
  const ownRef = useRef(null), ownStick = useRef(true);
  const ref = extRef || ownRef, stick = extStick || ownStick;
  const [away, setAway] = useState(false);
  /* A CALLBACK ref, not a plain one: this hook lives in a component that
     renders the auth gate first, so the scroller mounts several renders later
     and a plain ref would sit at null with nothing to re-trigger setup. The
     external ref object is still filled in, so the streaming callbacks that
     read scrollRef.current keep working untouched. */
  const [node, setNode] = useState(null);
  const attach = useCallback(el => { ref.current = el; setNode(el); }, []);
  useEffect(() => {
    const el = node; if (!el) return;
    const gapNow = () => el.scrollHeight - el.scrollTop - el.clientHeight;
    /* Pinning is INSTANT even though .scroll is smooth: a smooth scroll emits
       intermediate scroll events whose gap is still large, which would turn
       following off halfway through its own animation. */
    const sync = () => {
      if (stick.current) { jumpNow(el); setAway(false); }
      else setAway(gapNow() > 240);      /* far enough that the button earns its place */
    };
    const onScroll = () => { const g = gapNow(); stick.current = g < 80; setAway(g > 240); };
    el.addEventListener("scroll", onScroll, { passive: true });
    const ro = typeof ResizeObserver === "function" ? new ResizeObserver(sync) : null;
    if (ro) { ro.observe(el); if (el.firstElementChild) ro.observe(el.firstElementChild); }
    /* The observer covers a visible tab; these cover the rest: a browser
       without ResizeObserver, and a tab that is hidden while the thread opens
       (no frames, so no observer callbacks either). Cheap and idempotent. */
    const settle = [0, 250, 700].map(ms => setTimeout(sync, ms));
    stick.current = true; sync();
    return () => {
      el.removeEventListener("scroll", onScroll);
      if (ro) ro.disconnect();
      settle.forEach(clearTimeout);
    };
  }, [node, id]);
  const toBottom = useCallback(() => {
    const el = ref.current; if (!el) return;
    stick.current = true; setAway(false); jumpNow(el);
  }, []);
  return { ref: attach, away, toBottom };
}
function ScrollDown({ show, onClick }) {
  if (!show) return null;
  return (
    <button className="scroll-down" onClick={onClick} title={t("chat.toEnd")} aria-label={t("chat.toEnd")}>
      <svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor"
           strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
        <path d="M12 5v14M19 12l-7 7-7-7" />
      </svg>
    </button>
  );
}

/* The agent's LOOPS: standing missions run on a cadence ("monitor X", "handle
   support inbox"). This panel is the control surface — see each loop's prompt,
   edit it, start/stop it, fire it now. Any active loop (or service) makes the
   agent a background agent; pause them all and it returns to Foreground. */
function AgentView({ agent, onMenu, onRename, onSetBackend, busy, reconnecting, engineReady, onSend, onStop, orphan, onDismissOrphan, onOpenRoutines, box }) {
  const [editing, setEditing] = useState(false);
  const [draft, setDraft] = useState(agent.name);
  const [text, setText] = useState("");
  const [showFiles, setShowFiles] = useState(false);
  const [files, setFiles] = useState([]);
  const [filesLoading, setFilesLoading] = useState(false);
  const [dl, setDl] = useState(null);            /* path currently downloading */
  /* composer attachments (uploaded into the workspace) + voice input */
  const [attach, setAttach] = useState([]);
  const [drag, setDrag] = useState(false);
  const [recording, setRecording] = useState(false);
  const [transcribing, setTranscribing] = useState(false);
  const [voiceErr, setVoiceErr] = useState(null);
  const [priv, setPriv] = useState(false);
  const privacyAvailable = L.toolEnabled("privacy");
  const mcpAvailable = L.toolEnabled("mcp");
  /* No point offering the wiki on an agent that has no access to one. */
  const wikiAvailable = agent.wiki && agent.wiki !== "none";
  /* Whether this turn gets the wiki and the MCP tools. Per agent and remembered
     here rather than on the row: it is how YOU want to work with this agent,
     not something the agent is. The wiki starts OFF — most turns do not need it,
     and mounting a knowledge base into a coding job just adds noise. */
  const flagKey = "indie.agent.flags." + agent.id;
  const [useWiki, setUseWiki] = useState(() => {
    try { return JSON.parse(localStorage.getItem(flagKey) || "{}").wiki === true; } catch { return false; }
  });
  const [useTools, setUseTools] = useState(() => {
    try { return JSON.parse(localStorage.getItem(flagKey) || "{}").tools !== false; } catch { return true; }
  });
  useEffect(() => {
    try { localStorage.setItem(flagKey, JSON.stringify({ wiki: useWiki, tools: useTools })); } catch {}
  }, [flagKey, useWiki, useTools]);
  const fileInput = useRef(null);
  const dragDepth = useRef(0);
  const rec = useRef(null);
  const taRef = useRef(null);
  const voiceAvailable = typeof navigator !== "undefined" && navigator.mediaDevices && window.AudioContext;
  useEffect(() => { setDraft(agent.name); setEditing(false); setShowFiles(false); setFiles([]); setServices([]); setAttach([]); setText(""); }, [agent.id]);
  useEffect(() => { const el = taRef.current; if (!el) return; el.style.height = "auto"; el.style.height = Math.min(el.scrollHeight, 220) + "px"; el.style.overflowY = el.scrollHeight > 220 ? "auto" : "hidden"; }, [text]);
  const msgs = agent.messages || [];
  /* The agent transcript keeps itself pinned to the newest turn. Chat has done
     this since the beginning through the App's own refs, but those belong to
     the chat view's scroller — this one is a different element, so an agent
     answer used to stream away below the fold with nothing following it. */
  const scroller = useTranscriptScroll(agent.id);
  /* The newest turn that reported usage. Older turns keep theirs, so scrolling
     back shows what the window looked like then rather than now. */
  const lastContext = [...msgs].reverse().find(m => m && m.context) ?
    [...msgs].reverse().find(m => m && m.context).context : null;
  const modelLabel = (L.findModel(agent.model) || {}).label || agent.model || "—";
  /* The ENGINE's own name: "custom" here is an agent backend, not the chat's "your own endpoint". */
  const provLabel  = agent.backendLabel || L.providerLabel(agent.endpoint) || agent.endpoint || "—";

  /* Visual canvas: every payload the agent has produced (persisted on messages)
     plus any live ones this session, with a selector to switch between them.
     Auto-opens + selects the newest when a fresh one arrives; toggleable. */
  const canvases = [
    ...msgs.flatMap(m => (m.role === "assistant" && Array.isArray(m.canvases)) ? m.canvases : []),
    ...(agent.liveCanvases || []),
  ];
  const [canvasOpen, setCanvasOpen] = useState(false);
  const [sel, setSel] = useState(0);
  const liveLen = (agent.liveCanvases || []).length;
  const prevLive = useRef(0);
  useEffect(() => { if (liveLen > prevLive.current) { setCanvasOpen(true); setSel(canvases.length - 1); } prevLive.current = liveLen; }, [liveLen]);
  useEffect(() => { setCanvasOpen(false); setSel(0); prevLive.current = (agent.liveCanvases || []).length; }, [agent.id]);
  const canvas = canvases.length ? canvases[Math.min(sel, canvases.length - 1)] : null;

  const [services, setServices] = useState([]);
  const loadFiles = async () => {
    setFilesLoading(true);
    try { const r = await L.AgentsAPI.files(agent.id); setFiles(r.files || []); }
    catch { setFiles([]); }
    finally { setFilesLoading(false); }
    L.AgentsAPI.services(agent.id).then(r => setServices(r.services || [])).catch(() => setServices([]));
  };
  const stopSvc = async name => {
    try { await L.AgentsAPI.stopService(agent.id, name); setServices(ss => ss.filter(s => s.name !== name)); }
    catch (e) { alert(t("agent.svcStopFailed") + " " + ((e && e.message) || e)); }
  };
  const showSvcLogs = async name => {
    try { const r = await L.AgentsAPI.serviceLogs(agent.id, name); alert("Logs — " + name + "\n\n" + (r.logs || "(no output yet)")); }
    catch (e) { alert(t("agent.logsFailed") + " " + ((e && e.message) || e)); }
  };
  /* Refresh the file list when a turn finishes (the agent may have written files). */
  const wasBusy = useRef(busy);
  useEffect(() => {
    if (wasBusy.current && !busy && showFiles) loadFiles();
    wasBusy.current = busy;
  }, [busy]);
  const toggleFiles = () => { const n = !showFiles; setShowFiles(n); if (n) loadFiles(); };
  const download = async (p) => { setDl(p); try { await L.AgentsAPI.download(agent.id, p); } catch (e) { alert(t("file.downloadFailedWith") + " " + e.message); } finally { setDl(null); } };

  /* ---- attachments: uploaded into the workspace + converted for context ---- */
  const MAX_UPLOAD = 32 * 1024 * 1024;
  const patchAtt = (id, patch) => setAttach(fs => fs.map(x => x.id === id ? { ...x, ...patch } : x));
  const addFiles = list => {
    Array.from(list).forEach(f => {
      const id = Math.random().toString(36).slice(2);
      setAttach(fs => [...fs, { id, name: f.name, size: f.size, busy: true }]);
      if (f.size > MAX_UPLOAD) { patchAtt(id, { busy: false, error: "too large (max 32 MB)" }); return; }
      /* Put the real file in the workspace AND extract text for prompt context,
         through the SAME converter chat uses — so a scan is already OCR'd text
         by the time the agent sees it, instead of a file it has to work out how
         to read for itself. */
      const up = L.AgentsAPI.upload(agent.id, f);
      const cv = L.convertFile(f).catch(() => null);
      Promise.all([up, cv])
        .then(([u, c]) => patchAtt(id, {
          busy: false, path: u.path,
          text: c && c.markdown ? c.markdown : "",
          truncated: c && c.truncated, ocr: c && c.ocr, note: c && c.note,
        }))
        .catch(e => patchAtt(id, { busy: false, error: (e && e.message) || "upload failed" }));
    });
  };

  /* ---- voice input: mic → WAV → MarkItDown transcription (same as chat) ---- */
  const startRecording = async () => {
    setVoiceErr(null);
    try {
      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
      const ctx = new (window.AudioContext || window.webkitAudioContext)();
      const proc = ctx.createScriptProcessor(4096, 1, 1);
      const mic = ctx.createMediaStreamSource(stream);
      const chunks = [];
      proc.onaudioprocess = e => { chunks.push(new Float32Array(e.inputBuffer.getChannelData(0))); };
      mic.connect(proc); proc.connect(ctx.destination);
      rec.current = { ctx, processor: proc, source: mic, stream, chunks };
      setRecording(true);
    } catch (e) {
      setVoiceErr(e && e.name === "NotAllowedError" ? t("voice.denied") : t("voice.startFailed"));
    }
  };
  const stopRecording = async () => {
    const r = rec.current; rec.current = null;
    setRecording(false);
    if (!r) return;
    try { r.processor.disconnect(); r.source.disconnect(); } catch {}
    r.stream.getTracks().forEach(t => t.stop());
    const sampleRate = r.ctx.sampleRate;
    try { await r.ctx.close(); } catch {}
    const total = r.chunks.reduce((n, c) => n + c.length, 0);
    if (total < sampleRate * 0.3) { setVoiceErr(t("voice.tooShort")); return; }
    setTranscribing(true);
    try {
      const wav = L.pcmToWavBlob(r.chunks, sampleRate);
      const res = await L.convertFile(new File([wav], "voice.wav", { type: "audio/wav" }));
      const said = L.transcriptText(res.markdown);
      if (said) { setText(t => (t ? t.replace(/\s*$/, " ") : "") + said); requestAnimationFrame(() => taRef.current && taRef.current.focus()); }
      else setVoiceErr(t("voice.noSpeech"));
    } catch (e) { setVoiceErr((e && e.message) || t("voice.failed")); }
    finally { setTranscribing(false); }
  };
  const toggleMic = () => { if (transcribing) return; recording ? stopRecording() : startRecording(); };

  /* Files dropped anywhere on the window (see WindowDrop). No dependency array:
     addFiles closes over current state, so the listener is re-bound each render
     rather than capturing a stale one. Claiming the event stops a second
     mounted composer from adding the same file twice. */
  useEffect(() => {
    const onFiles = e => {
      if (e.defaultPrevented || !e.detail || !e.detail.length) return;
      e.preventDefault();
      addFiles(e.detail);
    };
    window.addEventListener("indie:files", onFiles);
    return () => window.removeEventListener("indie:files", onFiles);
  });

  /* One chooser per tap. A hidden input opened twice shows the picker twice,
     which is what a doubled activation looks like on a phone — and touch
     surfaces have more ways to double-fire than a mouse does. Cheap to make
     impossible rather than to keep diagnosing. */
  const pickerBusy = useRef(0);
  const openPicker = () => {
    const now = Date.now();
    if (now - pickerBusy.current < 800) return;
    pickerBusy.current = now;
    if (fileInput.current) fileInput.current.click();
  };

  const onDrop      = e => { e.preventDefault(); dragDepth.current = 0; setDrag(false); if (e.dataTransfer.files?.length) addFiles(e.dataTransfer.files); };
  const onDragEnter = e => { e.preventDefault(); dragDepth.current++; setDrag(true); };
  const onDragLeave = e => { e.preventDefault(); dragDepth.current--; if (dragDepth.current <= 0) setDrag(false); };
  /* Paste (Ctrl/Cmd+V) an image -> upload it into the workspace. */
  const onPaste = e => {
    const imgs = [...(e.clipboardData?.items || [])]
      .filter(it => it.kind === "file" && it.type.startsWith("image/"))
      .map(it => { const f = it.getAsFile(); if (!f) return null; return f.name ? f : new File([f], "pasted-" + Date.now() + "." + ((f.type.split("/")[1]) || "png"), { type: f.type }); })
      .filter(Boolean);
    if (imgs.length) { e.preventDefault(); addFiles(imgs); }
  };

  const converting = attach.some(f => f.busy);
  const submit = () => {
    const t = text.trim();
    if ((!t && !attach.length) || busy || converting || recording || transcribing) return;
    const ready = attach.filter(f => f.path && !f.error);
    onSend(t, ready, priv && privacyAvailable, { useWiki, useTools });
    setText(""); setAttach([]);
  };

  return (
    <div className={"agent-shell" + (canvasOpen && canvas ? " canvas-open" : "")}>
      <div className="agent-pane">
      <div className="topbar">
        <div style={{ display: "flex", alignItems: "center", minWidth: 0 }}>
          <button className="menu-btn" onClick={onMenu}><MenuIcon /></button>
          <div className="topbar-title">
            <span className="tt-kind">Agent</span>
            {editing
              ? <input className="agent-name-edit" value={draft} autoFocus
                       onChange={e => setDraft(e.target.value)}
                       onBlur={() => { setEditing(false); const n = draft.trim(); if (n && n !== agent.name) onRename(agent.id, n); }}
                       onKeyDown={e => { if (e.key === "Enter") e.target.blur(); if (e.key === "Escape") { setDraft(agent.name); setEditing(false); } }} />
              : <span className="tt-name" onClick={() => setEditing(true)} title={agent.name + " — click to rename"}>{agent.name}</span>}
          </div>
        </div>
        <div className="tools">
          <BoxBusy box={box} />
          <ContextGauge {...(lastContext || {})} />
          <AgentEngineSelect agent={agent} onPick={patch => onSetBackend(agent.id, patch)} />
          <span className="agent-tag">BETA</span>
          {agent.wiki && agent.wiki !== "none" && (
            <span className={"loop-chip" + (agent.wiki === "write" ? "" : " ro")}
                  title={agent.wiki === "write"
                    ? "Maintains the shared wiki at /wiki (read-write). It ingests sources, updates pages and keeps the index consistent."
                    : "Reads the shared wiki at /wiki (read-only). Ask the Wiki Librarian to write anything down."}>
              {agent.wiki === "write" ? "✎ wiki" : "▤ wiki"}
            </span>
          )}
          {agent.background && <span className="loop-chip" title={t("agent.backgroundHint")}>◉ loop</span>}
          <button className={"icon-btn" + (agent.background ? " on" : "")}
                  title={t("routine.agentHint")}
                  onClick={() => onOpenRoutines && onOpenRoutines(agent.id)}><ClockIcon /></button>
          {canvases.length > 0 && <button className={"icon-btn" + (canvasOpen ? " on" : "")} title={t("canvas.title")} onClick={() => setCanvasOpen(o => !o)}><CanvasIcon /></button>}
          <button className={"icon-btn" + (showFiles ? " on" : "")} title={t("agent.files")} onClick={toggleFiles}><FilesIcon /></button>
          <a className="icon-btn" title={t("nav.guideApps")} href="/help/apps.de.html" target="_blank" rel="noopener"><HelpIcon /></a>
          <a className="icon-btn" title={t("nav.settings")} href="/settings.html"><GearIcon /></a>
        </div>
      </div>

      {orphan && !busy && (
        <div style={{ padding: "10px 24px 0" }}>
          <div className="orphan-banner">
            <span className="sq" style={{ width: 8, height: 8, background: "var(--maroon)", flex: "none" }} />
            <span>A run was interrupted{orphan.title ? " — “" + orphan.title + "”" : ""} (server restart). The workspace and session are intact.</span>
            <button className="act-btn" onClick={() => { onSend(t("run.resumeHint"), [], false); onDismissOrphan && onDismissOrphan(orphan.id); }}>{t("run.resume")}</button>
            <button className="x" onClick={() => onDismissOrphan && onDismissOrphan(orphan.id)}>✕</button>
          </div>
        </div>
      )}

      <div className="scroll-wrap">
        <div className="scroll" ref={scroller.ref}>
          {msgs.length === 0
            ? (
              <div className="agent-empty">
                <div className="agent-empty-ic"><BotIcon /></div>
                <h2>{agent.name}</h2>
                <p>{tb("agent.emptyDesc")}</p>
                <p className="agent-empty-model">{modelLabel} · {t("common.via")} {provLabel}</p>
              </div>
            )
            : (
              <div className="thread">
                {msgs.map((m, i) => (
                  <Message key={i} m={m}
                           modelLabel={(L.findModel(m.servedModel || m.model) || {}).label || m.model || agent.name}
                           provider={L.providerLabel(m.endpoint)}
                           onDownloadFile={download}
                           streaming={busy && i === msgs.length - 1 && m.role === "assistant"} />
                ))}
              </div>
            )}
        </div>
        <ScrollDown show={scroller.away} onClick={scroller.toBottom} />
      </div>

      {showFiles && (
        <div className="agent-files">
          {services.length > 0 && (
            <div style={{ padding: "8px 14px", borderBottom: "1px solid var(--line)" }}>
              <div style={{ fontFamily: "var(--mono)", fontSize: 10, letterSpacing: "0.08em", textTransform: "uppercase", color: "var(--ink-4)", marginBottom: 6 }}>{t("agent.services")}</div>
              {services.map(s => (
                <div key={s.name} style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 12.5, padding: "3px 0" }}>
                  <span style={{ width: 7, height: 7, borderRadius: "50%", flex: "none", background: s.status === "running" ? "var(--green)" : "var(--maroon)" }} />
                  <span style={{ fontFamily: "var(--mono)", fontSize: 11.5 }}>{s.name}</span>
                  <span style={{ color: "var(--ink-4)", fontSize: 11 }}>{s.status}</span>
                  <span style={{ marginLeft: "auto", display: "flex", gap: 6 }}>
                    <button className="agent-files-refresh" onClick={() => showSvcLogs(s.name)}>{t("agent.logs")}</button>
                    <button className="agent-files-refresh" style={{ color: "var(--maroon)" }} onClick={() => stopSvc(s.name)}>{t("common.stop")}</button>
                  </span>
                </div>
              ))}
            </div>
          )}
          <div className="agent-files-head">
            <span>Workspace files {files.length ? "(" + files.length + ")" : ""}</span>
            <button className="agent-files-refresh" onClick={loadFiles} disabled={filesLoading}>{filesLoading ? "Loading…" : "Refresh"}</button>
          </div>
          {!filesLoading && files.length === 0 && <div className="agent-files-empty">{t("agent.noFiles")}</div>}
          {files.length > 0 && (
            <div className="agent-files-list">
              {files.map(f => (
                <div className="agent-file" key={f.path}>
                  <span className="agent-file-name" title={f.path}>{f.path}</span>
                  <span className="agent-file-size">{fmtBytes(f.size)}</span>
                  <button className="agent-file-dl" onClick={() => download(f.path)} disabled={dl === f.path} title={t("common.download")}>
                    {dl === f.path ? "…" : <DownloadIcon />}
                  </button>
                </div>
              ))}
            </div>
          )}
        </div>
      )}

      {reconnecting && (
        <div style={{ padding: "0 24px" }}>
          <div className="reconnect-banner"><span className="rec-dot busy" /> {t("agent.reconnecting")}</div>
        </div>
      )}

      {engineReady === false ? (
        <div className="composer-wrap">
          <div className="agent-coming">
            ⚙ {tb("agent.engineMissing")}
          </div>
        </div>
      ) : (
        <div className="composer-wrap">
          <div className={"composer agent-composer " + (drag ? "drag" : "")}
               onDrop={onDrop} onDragOver={e => e.preventDefault()}
               onDragEnter={onDragEnter} onDragLeave={onDragLeave}>
            {attach.length > 0 && (
              <div className="attach-row">
                {attach.map(f => (
                  <div key={f.id} className={"chip " + (f.error ? "bin " : "") + (f.busy ? "loading" : "")}
                       title={f.error ? f.error : f.busy ? t("agent.uploading") : (f.truncated ? f.name + " (text truncated for context)" : f.name)}>
                    <span className="chip-ic" />
                    <span className="chip-name">{f.name}</span>
                    <span className="chip-sz">{f.busy ? "uploading…" : f.error ? "failed" : fmtBytes(f.size)}</span>
                    <button className="chip-x" onClick={() => setAttach(fs => fs.filter(x => x.id !== f.id))}>✕</button>
                  </div>
                ))}
              </div>
            )}
            <div className="composer-row">
              <button type="button" className="attach-btn" title={t("agent.attachHint")} onClick={openPicker}><ClipIcon /></button>
              <input ref={fileInput} type="file" multiple hidden onChange={e => { addFiles(e.target.files); e.target.value = ""; }} />
              <textarea ref={taRef} rows={1} value={text}
                        placeholder={drag ? t("agent.dropFiles") : t("agent.placeholder")}
                        onChange={e => setText(e.target.value)}
                        onPaste={onPaste}
                        onKeyDown={e => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); } }} />
              {voiceAvailable && (
                <button className={"mic-btn " + (recording ? "rec " : "") + (transcribing ? "busy" : "")}
                        onClick={toggleMic} disabled={transcribing}
                        title={recording ? t("voice.stopAndTranscribe") : transcribing ? t("voice.transcribing") : t("voice.input")}>
                  <MicIcon />
                </button>
              )}
              {busy
                ? <button className="stop" onClick={onStop} title={t("common.stop")}><span className="sq" /></button>
                : <button className="send" onClick={submit} disabled={converting || recording || transcribing || (!text.trim() && !attach.length)} title={converting ? t("agent.uploadingAll") : "Send"}><SendIcon /></button>}
            </div>
          </div>
          <div className="composer-foot">
            <div className="foot-left">
              {wikiAvailable && (
                <button className={"tool-toggle " + (useWiki ? "on" : "")} onClick={() => setUseWiki(v => !v)}
                        title={t("agent.wikiHint")}>
                  <BookIcon /> {t("agent.useWiki")}
                </button>
              )}
              {mcpAvailable && (
                <button className={"tool-toggle " + (useTools ? "on" : "")} onClick={() => setUseTools(v => !v)}
                        title={t("agent.toolsHint")}>
                  <ToolsIcon /> {t("tools.tools")}
                </button>
              )}
              {privacyAvailable && (
                <button className={"tool-toggle " + (priv ? "on" : "")} onClick={() => setPriv(p => !p)}
                        title={t("agent.privacyHint")}>
                  <ShieldIcon /> {t("tools.private")} <span className="tt-sub">(OPF)</span>
                </button>
              )}
              {recording
                ? <span className="voice-status rec"><span className="rec-dot" /> {t("voice.recording")}</span>
                : transcribing
                ? <span className="voice-status"><span className="rec-dot busy" /> {t("voice.transcribing")}</span>
                : voiceErr
                ? <span className="voice-status err">{voiceErr}</span>
                : <span className="hint">{tb("agent.composerHint")}</span>}
            </div>
          </div>
        </div>
      )}
      </div>

      {canvasOpen && canvas && (
        <div className="agent-canvas">
          <div className="agent-canvas-head">
            <span className="acv-kind">{canvas.kind}</span>
            <span className="acv-title" title={canvas.title || canvas.path || ""}>{canvas.title || canvas.path || "Canvas"}</span>
            {canvas.kind === "url" && <a className="acv-open" href={canvas.content} target="_blank" rel="noreferrer">{t("canvas.open")}</a>}
            {(canvas.kind === "pdf" || canvas.kind === "xlsx") && <button className="acv-open" onClick={() => download(canvas.path)}>{t("common.download")}</button>}
            <button className="acv-close" title={t("canvas.close")} onClick={() => setCanvasOpen(false)}>✕</button>
          </div>
          {canvases.length > 1 && (
            <div className="acv-tabs">
              {canvases.map((c, i) => (
                <button key={i} className={"acv-tab" + (i === Math.min(sel, canvases.length - 1) ? " on" : "")}
                        title={c.title || c.path || c.kind} onClick={() => setSel(i)}>
                  {c.title || c.path || c.kind}
                </button>
              ))}
            </div>
          )}
          <div className="agent-canvas-body">
            <AgentCanvas key={sel + "|" + canvas.kind + (canvas.path || "") + (canvas.title || "")} agentId={agent.id} payload={canvas} />
          </div>
        </div>
      )}
    </div>
  );
}

/* ========================================================
   APPS — modular workspace tabs (dashboards, CRMs, notes, …)
   A sandboxed iframe renders the app document in a CENTRAL pane while the
   linked agent's chat docks on the right. The iframe can only talk through
   the postMessage SDK below: persisted versioned state + named data
   connectors — no cookies, no origin, no direct API access.
   ======================================================== */
const APP_SDK =
  '<script>window.IndieApp=(function(){var seq=0,pend={},scbs=[];' +
  'window.addEventListener("message",function(e){var d=e.data;if(!d||!d.__indieapp)return;' +
  'if(d.event==="state"){for(var i=0;i<scbs.length;i++){try{scbs[i](d.state,d.v)}catch(x){}}return}' +
  'if(d.toolCall)return;' +
  'var p=pend[d.id];if(!p)return;delete pend[d.id];d.error?p[1](new Error(d.error)):p[0](d.result)});' +
  'function call(m,params){return new Promise(function(res,rej){var id=++seq;pend[id]=[res,rej];' +
  'parent.postMessage({__indieapp:true,id:id,method:m,params:params||{}},"*")})}' +
  'return{getState:function(){return call("get_state")},' +
  'setState:function(patch,replace){return call("set_state",{patch:patch,replace:!!replace})},' +
  'fetch:function(connector,params){return call("fetch",{connector:connector,params:params})},' +
  'ask:function(text){return call("ask",{text:text})},' +
  'onState:function(cb){scbs.push(cb)}}})();' +
  /* WebMCP (navigator.modelContext) polyfill: apps register tools the standard
     way; the host relays agent tool-calls into execute() and results back.
     Defers to a native implementation if the browser ever ships one. */
  '(function(){var reg={},order=[];' +
  'function meta(){return order.map(function(n){var t=reg[n];return{name:t.name,description:t.description||"",inputSchema:t.inputSchema||{type:"object",properties:{}}}})}' +
  'function announce(){try{parent.postMessage({__indieapp:true,event:"tools",tools:meta()},"*")}catch(e){}}' +
  'function add(t){if(!t||typeof t.name!=="string"||!t.name.trim()||typeof t.execute!=="function")return false;' +
  'if(!reg[t.name])order.push(t.name);reg[t.name]=t;return true}' +
  'var native=navigator.modelContext;' +
  'var api={provideContext:function(ctx){reg={};order=[];(((ctx||{}).tools)||[]).forEach(add);announce();' +
  'if(native&&native.provideContext){try{native.provideContext(ctx)}catch(e){}}},' +
  'registerTool:function(t){if(add(t))announce();' +
  'if(native&&native.registerTool){try{native.registerTool(t)}catch(e){}}' +
  'return{unregister:function(){delete reg[t.name];order=order.filter(function(n){return n!==t.name});announce()}}}};' +
  'try{Object.defineProperty(navigator,"modelContext",{value:api,configurable:true})}catch(e){navigator.modelContext=api}' +
  'window.addEventListener("message",function(e){var d=e.data;if(!d||!d.__indieapp||!d.toolCall)return;' +
  'var tc=d.toolCall,t=reg[tc.name];' +
  'function reply(result,error){try{parent.postMessage({__indieapp:true,toolResult:{id:tc.id,result:result,error:error}},"*")}catch(x){}}' +
  'if(!t)return reply(null,"Unknown tool: "+tc.name);' +
  'Promise.resolve().then(function(){return t.execute(tc.input||{})}).then(function(out){' +
  'if(out==null)out={content:[{type:"text",text:"ok"}]};' +
  'if(typeof out==="string")out={content:[{type:"text",text:out}]};' +
  'reply(out)},function(err){reply(null,String((err&&err.message)||err))})});' +
  '})();</' + 'script>';
/* Inject the SDK inside <head> so a full document keeps its doctype (and
   standards mode); fall back to prepending for bare fragments. */
function appSrcDoc(html) {
  const h = String(html || "");
  if (/<head[^>]*>/i.test(h)) return h.replace(/<head[^>]*>/i, m => m + APP_SDK);
  return APP_SDK + h;
}

/* Personal <-> shared, for admins. The consequences live IN the panel rather
   than in a tooltip: both of them are invisible at the moment of clicking and
   expensive to find out afterwards, and one of them ("everyone can now read
   what is saved in here") is somebody's private data. */
function AppScopeControl({ app, onSetScope }) {
  const [open, setOpen] = useState(false);
  const [busy, setBusy] = useState(false);
  const [warn, setWarn] = useState(null);
  const ref = useRef(null);
  const isGlobal = app.scope === "global";
  useEffect(() => {
    const h = e => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
    document.addEventListener("mousedown", h);
    return () => document.removeEventListener("mousedown", h);
  }, []);
  useEffect(() => { setWarn(null); setOpen(false); }, [app.id]);
  const go = async () => {
    setBusy(true);
    try {
      const r = await onSetScope(isGlobal ? "personal" : "global");
      setWarn((r && r.warnings) || null);
      setOpen(false);
    } finally { setBusy(false); }
  };
  return (
    <div className="app-scope" ref={ref}>
      <button className={"app-scope-btn" + (isGlobal ? " on" : "")} onClick={() => setOpen(!open)}>
        {isGlobal ? t("app.scopeGlobal") : t("app.scopePersonal")}
      </button>
      {open && (
        <div className="app-scope-panel">
          <div className="menu-cap">{t("app.scopeCap")}</div>
          <p>{isGlobal ? tb("app.scopeToPersonal") : tb("app.scopeToGlobal")}</p>
          <ul>
            <li>{isGlobal ? t("app.scopeDataPersonal") : t("app.scopeDataGlobal")}</li>
            <li>{isGlobal ? t("app.scopeConnPersonal") : t("app.scopeConnGlobal")}</li>
          </ul>
          <div className="app-scope-actions">
            <button className="act-btn" onClick={() => setOpen(false)}>{t("common.cancel")}</button>
            <button className="act-btn primary" disabled={busy} onClick={go}>
              {busy ? t("common.pleaseWait") : (isGlobal ? t("app.scopeMakePersonal") : t("app.scopeMakeGlobal"))}
            </button>
          </div>
        </div>
      )}
      {/* What the server found once it had the app in front of it: the actual
          connector names, which the panel above can only describe in general. */}
      {warn && warn.length > 0 && (
        <div className="app-scope-panel done">
          <div className="menu-cap">{t("app.scopeChanged")}</div>
          {warn.map((w, i) => <p key={i}>{w.text}</p>)}
          <div className="app-scope-actions">
            <button className="act-btn" onClick={() => setWarn(null)}>{t("common.ok")}</button>
          </div>
        </div>
      )}
    </div>
  );
}

function AppWorkspace({ app, busy, appStateTick, appHtmlTick, onAsk, onMenu, locked, onLock,
                        agents, dockedAgent, onLinkAgent, onNewAgent, isAdmin, onSetScope, box }) {
  const [full, setFull] = useState(null);       /* { html, state, version } */
  const [err, setErr] = useState(null);
  const [frameKey, setFrameKey] = useState(0);  /* bump to reload the iframe */
  const [toolCount, setToolCount] = useState(0);
  const [agentMenu, setAgentMenu] = useState(false);
  const frameRef = useRef(null);
  const stateRef = useRef({});
  const verRef = useRef(0);
  const toolsRef = useRef([]);      /* WebMCP tools the iframe registered */
  const bridgeIdRef = useRef(null); /* live SSE bridge to the server */

  /* Load the document (fresh iframe). Re-runs when the agent rewrites the
     html (appHtmlTick) or the user hits reload. */
  useEffect(() => {
    let gone = false;
    setErr(null);
    /* fresh document -> stale tool registrations die with it */
    toolsRef.current = []; setToolCount(0);
    if (bridgeIdRef.current) L.AppsAPI.postTools(app.id, bridgeIdRef.current, []).catch(() => {});
    L.AppsAPI.get(app.id)
      .then(f => { if (gone) return; stateRef.current = f.state || {}; verRef.current = f.version || 0; setFull(f); })
      .catch(e => { if (!gone) setErr((e && e.message) || t("app.loadFailed")); });
    return () => { gone = true; };
  }, [app.id, frameKey, appHtmlTick]);
  useEffect(() => { setFull(null); }, [app.id]);   /* don't show the old app while switching */

  /* WebMCP bridge: a long-lived channel the server uses to run this app's
     registered tools when the agent calls them (app_call). We relay the call
     into the sandboxed iframe and post execute()'s result back. */
  useEffect(() => {
    const ctrl = new AbortController();
    L.bridgeApp(app.id, {
      onOpen: id => {
        bridgeIdRef.current = id;
        if (toolsRef.current.length) L.AppsAPI.postTools(app.id, id, toolsRef.current).catch(() => {});
      },
      onCall: evt => {
        const w = frameRef.current && frameRef.current.contentWindow;
        if (!w) { L.AppsAPI.postCallResult(app.id, evt.callId, null, t("app.notRendered")).catch(() => {}); return; }
        try { w.postMessage({ __indieapp: true, toolCall: { id: evt.callId, name: evt.tool, input: evt.input || {} } }, "*"); }
        catch { L.AppsAPI.postCallResult(app.id, evt.callId, null, t("app.frameUnreachable")).catch(() => {}); }
      },
    }, ctrl.signal);
    return () => { ctrl.abort(); bridgeIdRef.current = null; };
  }, [app.id]);

  const postState = () => {
    const w = frameRef.current && frameRef.current.contentWindow;
    if (w) { try { w.postMessage({ __indieapp: true, event: "state", state: stateRef.current, v: verRef.current }, "*"); } catch {} }
  };

  /* Live state: the agent writes state server-side (app_state) and the
     server's background refreshers keep updating it on their own, so poll
     the cheap versioned endpoint — fast while the agent runs, gently while
     idle — and push changes into the iframe. */
  useEffect(() => {
    if (!full) return;
    let stop = false;
    const check = async () => {
      try {
        const r = await L.AppsAPI.state(app.id, verRef.current);
        if (!stop && r && !r.unchanged) { stateRef.current = r.state; verRef.current = r.v; postState(); }
      } catch {}
    };
    check();
    const t = setInterval(check, busy ? 2500 : 25000);
    return () => { stop = true; clearInterval(t); };
  }, [busy, appStateTick, full]);

  /* postMessage bridge — the ONLY door out of the sandbox. Validates the
     source window and answers with the user's own auth (cookie/token stays
     out of the iframe). */
  useEffect(() => {
    const onMsg = async e => {
      const w = frameRef.current && frameRef.current.contentWindow;
      if (!w || e.source !== w) return;
      const d = e.data;
      if (!d || !d.__indieapp) return;
      /* WebMCP: the iframe announced its registered tools */
      if (d.event === "tools") {
        toolsRef.current = Array.isArray(d.tools) ? d.tools : [];
        setToolCount(toolsRef.current.length);
        if (bridgeIdRef.current) L.AppsAPI.postTools(app.id, bridgeIdRef.current, toolsRef.current).catch(() => {});
        return;
      }
      /* WebMCP: execute() finished — relay the result to the waiting agent */
      if (d.toolResult) {
        L.AppsAPI.postCallResult(app.id, d.toolResult.id, d.toolResult.result, d.toolResult.error).catch(() => {});
        return;
      }
      if (!d.method) return;
      const reply = (result, error) => { try { w.postMessage({ __indieapp: true, id: d.id, result, error }, "*"); } catch {} };
      try {
        if (d.method === "get_state") reply(stateRef.current);
        else if (d.method === "set_state") {
          const r = await L.AppsAPI.setState(app.id, (d.params && d.params.patch) || {}, !!(d.params && d.params.replace));
          stateRef.current = r.state; verRef.current = r.v;
          reply(r.state); postState();
        }
        else if (d.method === "fetch") reply(await L.AppsAPI.connector(d.params && d.params.connector, d.params && d.params.params, app.id));
        else if (d.method === "ask") {
          if (onAsk) { onAsk(String((d.params && d.params.text) || "").slice(0, 4000)); reply({ ok: true }); }
          else reply(null, t("app.noAgentLinked"));
        }
        else reply(null, "Unknown method: " + d.method);
      } catch (ex) { reply(null, (ex && ex.message) || String(ex)); }
    };
    window.addEventListener("message", onMsg);
    return () => window.removeEventListener("message", onMsg);
  }, [app.id, onAsk]);

  return (
    <div className="appws-stage">
      <div className="topbar">
        <div style={{ display: "flex", alignItems: "center", minWidth: 0 }}>
          <button className="menu-btn" onClick={onMenu}><MenuIcon /></button>
          <div className="topbar-title">
            <span className="tt-kind">App</span>
            <span className="tt-name" title={app.name}>{(app.icon ? app.icon + " " : "") + app.name}</span>
          </div>
          {toolCount > 0 && (
            <span className="app-tools-chip" title={"This app exposes " + toolCount + " WebMCP tool" + (toolCount > 1 ? "s" : "") + " the agent can call"}>
              ⚡ {toolCount}
            </span>
          )}
        </div>
        <div className="tools">
          <BoxBusy box={box} />
          {/* Which agent is docked beside this app — switchable, and
              disconnectable without deleting anything. */}
          <div className="app-agent-sel">
            <button className={"app-agent-btn" + (dockedAgent ? "" : " none")}
                    onClick={() => setAgentMenu(o => !o)}
                    title={dockedAgent
                      ? t("app.dockedAgentWith") + " " + dockedAgent.name + " — click to switch or disconnect"
                      : t("app.noAgentDocked")}>
              <BotIcon />
              <span className="aa-name">{dockedAgent ? dockedAgent.name : "No agent"}</span>
              <span className="aa-caret">▾</span>
            </button>
            {agentMenu && (<>
              <div className="new-scrim" onClick={() => setAgentMenu(false)} />
              <div className="app-agent-menu">
                <div className="aam-head mono">{t("app.dockedAgent")}</div>
                <div className="aam-list">
                  {(agents || []).map(a => (
                    <button key={a.id} className={a.id === app.agentId ? "on" : ""}
                            onClick={() => { setAgentMenu(false); if (a.id !== app.agentId) onLinkAgent(a.id); }}>
                      <BotIcon /> <span className="aa-name">{a.name || "Agent"}</span>
                      {a.scope === "global" && <span className="app-scope">G</span>}
                    </button>
                  ))}
                </div>
                <button className="aam-new" onClick={() => { setAgentMenu(false); onNewAgent(); }}>
                  <PlusIcon /> {t("app.newAgentForApp")}
                </button>
                {app.agentId && (
                  <button className="aam-off" onClick={() => { setAgentMenu(false); onLinkAgent(null); }}>
                    ⊘ {t("app.disconnectAgent")}
                  </button>
                )}
              </div>
            </>)}
          </div>
          {isAdmin && <AppScopeControl app={app} onSetScope={onSetScope} />}
          <button className={"app-lock" + (locked ? " on" : "")} onClick={() => onLock(!locked)}
                  title={locked
                    ? "Code locked — the agent can use this app's tools and data but can't rewrite or delete it. Click to unlock."
                    : "Code unlocked — the agent may rebuild this app when a request needs it. Click to lock it down."}>
            <LockIcon open={!locked} /> {locked ? "Locked" : "Unlocked"}
          </button>
          <a className="icon-btn" title={t("nav.guideApps")} href="/help/apps.de.html" target="_blank" rel="noopener"><HelpIcon /></a>
          <button className="icon-btn" title={t("app.reload")} onClick={() => setFrameKey(k => k + 1)}><RefreshIcon /></button>
        </div>
      </div>
      {err && <div className="appws-note">{err}</div>}
      {!full && !err && <div className="appws-note mono">{t("common.loading")}</div>}
      {full && (
        <iframe key={frameKey + ":" + appHtmlTick} ref={frameRef} className="appws-frame" title={app.name}
                /* allow-downloads: an app may hand the user a file it built from
                   its own data (a CSV export). allow-modals: embedded tools
                   guard destructive actions with confirm(), which a sandbox
                   without the flag makes return false SILENTLY — the button
                   looks dead. Still no allow-same-origin, so the document
                   keeps its opaque origin. */
                sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads allow-modals"
                srcDoc={appSrcDoc(full.html)} />
      )}
    </div>
  );
}

/* Right-pane placeholder until an agent is linked to the app. */
function AppAgentPicker({ agents, onPick, onNew }) {
  return (
    <div className="app-agent-pick">
      <div className="agent-empty-ic"><BotIcon /></div>
      <h3>{t("app.pickAgent")}</h3>
      <p>{t("app.pickAgentDesc")}</p>
      {(agents || []).map(a => (
        <button key={a.id} className="app-pick-btn" onClick={() => onPick(a.id)}><BotIcon /> {a.name}</button>
      ))}
      <button className="app-pick-btn new" onClick={onNew}><PlusIcon /> {t("app.newAgentForApp")}</button>
    </div>
  );
}

/* Destructive-action confirm. Deleting an agent or an app throws away real
   state — a persistent workspace volume, running services, a definition other
   people share — so the consequence is spelled out rather than left to a
   generic "are you sure". Escape and the scrim both cancel; the confirm button
   takes focus but is never triggered by a stray Enter on the page. */
function ConfirmModal({ title, body, confirmLabel, onConfirm, onClose }) {
  useEffect(() => {
    const onKey = e => { if (e.key === "Escape") onClose(); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [onClose]);
  return (
    <div className="modal-scrim" onMouseDown={e => { if (e.target === e.currentTarget) onClose(); }}>
      <div className="modal confirm-modal">
        <div className="modal-head">
          <h2><span className="eb">{t("common.confirm")}</span>{title}</h2>
          <button className="modal-x" onClick={onClose}>✕</button>
        </div>
        <div className="modal-body">{body}</div>
        <div className="modal-foot">
          <span style={{ flex: 1 }} />
          <button className="btn" onClick={onClose}>{t("common.cancel")}</button>
          <button className="btn danger" autoFocus onClick={onConfirm}>{confirmLabel || "Delete"}</button>
        </div>
      </div>
    </div>
  );
}

/* "New app" template chooser. The list comes from the SERVER (/api/config), so
   the templates a deployment ships in its config appear here with no client
   change — and a module that is switched off takes its templates with it.
   The built-ins below are only the pre-config fallback. */
const appTemplateFallback = () => [
  { key: "finance", icon: "📈", name: "Markets", description: t("tpl.finance") },
  { key: "notes",   icon: "🗒️", name: "Notes", description: t("tpl.notes") },
  { key: "blank",   icon: "▦",  name: "New app", description: t("tpl.blank") },
];
/* The server writes the blurb, so a config-supplied template keeps the wording
   its operator chose. The five built-ins have a catalogue key and use it. */
const BUILTIN_TPL = ["finance", "notes", "wiki", "brain", "my-wiki", "my-brain", "blank"];
const tplBlurb = tp => (BUILTIN_TPL.includes(tp.key) ? t("tpl." + tp.key) : "") || tp.description || tp.desc;
function AppPickerModal({ onCreate, onClose, creating, isAdmin }) {
  const [global, setGlobal] = useState(false);
  const templates = L.appTemplateList() || appTemplateFallback();
  return (
    <div className="modal-scrim" onMouseDown={e => { if (e.target === e.currentTarget) onClose(); }}>
      <div className="modal app-picker">
        <div className="modal-head">
          <div><span className="eb">{t("sidebar.apps")}</span><h2>{t("sidebar.newApp")}</h2></div>
          <button className="modal-x" onClick={onClose}>✕</button>
        </div>
        <div className="modal-body">
          {templates.map(tp => (
            <button key={tp.key} className="app-tpl" disabled={!!creating} onClick={() => onCreate(tp.key, global)}>
              <span className="app-tpl-ic">{tp.icon}</span>
              <span className="app-tpl-tx">
                <b>{tp.name}</b>
                <span>{tplBlurb(tp)}</span>
              </span>
              {creating === tp.key && <span className="app-tpl-busy">{t("app.creating")}</span>}
            </button>
          ))}
          {isAdmin && (
            <label className="app-global-opt">
              <input type="checkbox" checked={global} onChange={e => setGlobal(e.target.checked)} />
              {t("app.globalOpt")}
            </label>
          )}
        </div>
      </div>
    </div>
  );
}

function App() {
  const ui = L.load();

  /* --- auth state --- */
  const [user,         setUser]         = useState(L.getCachedUser()); // show cached immediately
  /* Derived once HERE. Sidebar has its own copy of this line, and a prop that
     read `isAdmin` from App's scope was a bare undefined identifier: React does
     not warn, it throws, and a ReferenceError during render takes the entire
     tree down rather than the one control that wanted it. */
  const isAdmin = !!user && ["owner", "admin"].includes(user.role);
  const [authChecked,  setAuthChecked]  = useState(false);
  /* The origin answered nothing at all — not "signed out". Kept apart from
     `user` so an unreachable server never renders as a login prompt. */
  const [unreachable,  setUnreachable]  = useState(false);
  const [recheck,      setRecheck]      = useState(false);
  /* Dropping the worker and reloading, so the browser can show its own dialog. */
  const [reloading,    setReloading]    = useState(false);

  /* --- chat state --- */
  const [convs,    setConvs]    = useState([]);
  const [agents,   setAgents]   = useState([]);
  const [activeId, setActiveId] = useState(ui.activeId || null);
  const [activeKind, setActiveKind] = useState(ui.activeKind || "chat");   /* 'chat' | 'agent' */
  /* --- apps (modular workspace tabs) --- */
  const [apps, setApps] = useState([]);
  const [activeAppId, setActiveAppId] = useState(ui.activeAppId || null);
  const [showAppPicker, setShowAppPicker] = useState(false);
  const [creatingApp, setCreatingApp] = useState(null);
  const [confirm, setConfirm] = useState(null);   /* pending destructive action */
  /* Pane sizes are the user's and they persist. Their own store, because
     L.save() deliberately whitelists only the session keys. A null value means
     "back to the stylesheet default" — that's what double-clicking a divider
     does. Written on a short debounce so a drag doesn't hammer localStorage. */
  const [panes, setPanes] = useState(() => L.getJSON("indie.panes", {}) || {});
  const onPane = (k, v) => setPanes(p => ({ ...p, [k]: v }));
  const panesSaveRef = useRef(null);
  useEffect(() => {
    clearTimeout(panesSaveRef.current);
    panesSaveRef.current = setTimeout(() => L.setJSON("indie.panes", panes), 250);
    return () => clearTimeout(panesSaveRef.current);
  }, [panes]);
  const wsRef = useRef(null);         /* the app workspace, for the chat-split drag */
  const dragChat = x => {
    const r = wsRef.current && wsRef.current.getBoundingClientRect();
    if (!r) return;
    onPane("chat", Math.max(280, Math.min(Math.round(r.right - x), Math.max(320, r.width - 360))));
  };
  const [appStateTick, setAppStateTick] = useState(0);  /* agent changed some app's state */
  const [appHtmlTicks, setAppHtmlTicks] = useState({}); /* app id -> times the agent rewrote it */
  /* Apps stay MOUNTED after the user switches to a chat or an agent, hidden
     rather than torn down. Reason: an app's own memory is the only place it
     has for anything it must not persist (the iframe has no origin, so no
     localStorage, and its server-side state is shared and 256 KB).
     Unmounting on every sidebar click threw that away, so a table loaded
     from a local file was gone after one look at a chat, and its WebMCP
     tools with it. WARM_APPS bounds the iframes we keep.
     The list keeps INSERTION order and evicts the least recently used entry
     IN PLACE: React mirrors array order in the DOM, and moving an iframe
     element reloads it, which is the very thing this avoids. */
  const WARM_APPS = 4;
  const [warmApps_, setWarmApps_] = useState([]);   /* [{ id, at }], stable order */
  const warmSeq = useRef(0);
  useEffect(() => {
    if (!activeAppId) return;
    setWarmApps_(w => {
      const e = { id: activeAppId, at: ++warmSeq.current };
      const i = w.findIndex(x => x.id === activeAppId);
      if (i >= 0) { const n = w.slice(); n[i] = e; return n; }
      if (w.length < WARM_APPS) return [...w, e];
      const lru = w.reduce((m, x, k) => x.at < w[m].at ? k : m, 0);
      const n = w.slice(); n[lru] = e; return n;
    });
  }, [activeAppId]);
  const dropWarm = id => setWarmApps_(w => w.filter(x => x.id !== id));
  /* The active app counts as warm in the SAME render it becomes active, not
     one effect later, so the pane never blanks for a frame. */
  const warmApps = [...warmApps_.map(x => x.id), ...(activeAppId && !warmApps_.some(x => x.id === activeAppId) ? [activeAppId] : [])]
    .map(id => apps.find(a => a.id === id)).filter(Boolean);
  /* Sidebar tabs: foreground (interactive work) vs background (agents that
     work unattended — long runs, schedules). */
  const [sideTab, setSideTab] = useState("fg");
  const [activity, setActivity] = useState(null);       /* runs + schedules + load */
  const [activityTick, setActivityTick] = useState(0);  /* bump to refresh now */
  const [model,    setModel]    = useState(ui.model || L.DEFAULT_MODEL);
  /* Per-entry (conversation OR agent) run state, so chats and agents can run
     concurrently and you can leave one mid-turn without affecting another. */
  const [runningIds,      setRunningIds]      = useState({});   /* cid -> true while a run is in flight */
  const [reconnectingIds, setReconnectingIds] = useState({});   /* cid -> true while its stream is reconnecting */
  const [error,    setError]    = useState(null);
  const [convsLoaded, setConvsLoaded] = useState(false);
  const [agentsLoaded, setAgentsLoaded] = useState(false);
  const [endpoint, setEndpoint] = useState(localStorage.getItem("indie.endpoint") || null);
  const [provider, setProviderState] = useState(L.getProvider());
  const [navOpen,  setNavOpen]  = useState(false);
  const [preset,   setPreset]   = useState("");
  const [showRoutines, setShowRoutines] = useState(false);
  const [routinesFocus, setRoutinesFocus] = useState(null);   /* agent the panel opened from */
  const [mcpAvail, setMcpAvail] = useState(false);
  const [engineReady, setEngineReady] = useState(null);   /* agent engine status (null = unknown) */
  const [, setBrandV] = useState(0);                       /* bump to re-render when branding loads */
  const [cfgV, setCfgV] = useState(0);                     /* bump to re-ask the providers for models */

  const scrollRef = useRef(null);
  const stickRef  = useRef(true);
  const filegenRef = useRef(false);   /* was "Make file" on for the last send */
  const runsRef   = useRef(new Map()); /* cid -> { runId, ctrl } for in-flight runs */

  /* Per-entry run helpers. busy/reconnecting below reflect the ACTIVE entry. */
  const setRunning = (cid, on) => setRunningIds(m => { if (!!m[cid] === on) return m; const n = { ...m }; if (on) n[cid] = true; else delete n[cid]; return n; });
  const setReconn  = (cid, on) => setReconnectingIds(m => { if (!!m[cid] === on) return m; const n = { ...m }; if (on) n[cid] = true; else delete n[cid]; return n; });
  const busy         = !!runningIds[activeId];
  const reconnecting = !!reconnectingIds[activeId];

  const active        = activeKind === "chat" ? (convs.find(c => c.id === activeId) || null) : null;
  const activeAgent   = activeKind === "agent" ? (agents.find(a => a.id === activeId) || null) : null;
  const activeApp     = apps.find(a => a.id === activeAppId) || null;
  const appAgent      = activeApp && activeApp.agentId ? (agents.find(a => a.id === activeApp.agentId) || null) : null;
  /* An interrupted (server-restart) run for a given agent — drives the
     Resume banner in the agent view. */
  const orphanFor = id => ((activity && activity.runs) || []).find(r => r.agentId === id && r.status === "orphaned") || null;
  const dismissOrphan = async id => {
    setActivity(a => a ? { ...a, runs: a.runs.filter(r => r.id !== id) } : a);
    try { await L.AgentsAPI.dismissRun(id); } catch {}
  };
  const curProvider   = provider || (L.providersList()[0] || {}).name;
  /* The built-in catalog is the immediate answer; the provider's own list
     replaces it as soon as it arrives. Starting from the catalog means the
     picker is never empty, and never blocks on a gateway that is slow or
     unreachable. */
  const [providerModels, setProviderModels] = useState(() => L.modelsFor(curProvider));
  const modelLabel    = (L.findModel(model) || providerModels[0] || { label: model }).label;

  /* --- persist UI state (not conversations) --- */
  useEffect(() => { L.save({ activeId, model, activeKind, activeAppId }); }, [activeId, model, activeKind, activeAppId]);

  /* Ask the provider what it serves, and only THEN judge the selection.
     Validating against the built-in catalog would throw away a perfectly good
     saved choice, because that catalog is a subset of what the gateway has. */
  useEffect(() => {
    let gone = false;
    setProviderModels(L.modelsFor(curProvider));
    L.loadModels(curProvider).then(list => {
      /* null = the provider could not be asked. Then the selection stands:
         judging it against the built-in catalog is what used to reset a saved
         model to the default on every reload. */
      if (gone || !list || !list.length) return;
      setProviderModels(list);
      setModel(m => {
        if (list.some(x => x.id === m)) return m;          /* the saved pick is real */
        const preferred = L.defaultModelFor(curProvider);   /* else this provider's own default */
        return list.some(x => x.id === preferred) ? preferred : list[0].id;
      });
    }).catch(() => {});
    return () => { gone = true; };
  }, [curProvider, cfgV]);

  /* Load the account's MCP servers (global + own) into the cache, then discover
     whether any MCP tools are available (gates the Tools toggle). */
  useEffect(() => {
    if (!user) return;
    L.loadMcpServers().then(() => {
      if (L.toolEnabled("mcp")) L.mcpToolsList().then(r => setMcpAvail((r.tools || []).length > 0)).catch(() => {});
    });
  }, [user]);

  /* Load app-wide branding (public; works before auth) and re-render text once
     it arrives. Accent + title are applied immediately from cache on load. */
  useEffect(() => { L.loadBranding().then(() => setBrandV(v => v + 1)); }, []);

  /* Load what this DEPLOYMENT is: its providers, app templates and modules.
     Auth-gated, so it lands just after sign-in; the cached copy is applied
     synchronously at script load so a returning user never sees the built-in
     catalog first.

     This used to also drop a selected model that was not in the built-in
     catalog, which reset a perfectly good choice to the default on EVERY
     reload once the picker could offer what the gateway really serves. The
     catalog is a subset; only the provider can say what exists, so bumping
     cfgV re-asks it and that answer decides. */
  useEffect(() => {
    if (!user) return;
    L.loadConfig().then(() => { setBrandV(v => v + 1); setCfgV(v => v + 1); });
  }, [user]);

  /* Check the agent engine (Docker + image) when an agent is opened. The agent
     image is often still being PULLED when the app first answers (a fresh
     install, or an update that bumped the image tag), so while docker is up but
     the image is missing keep polling and clear the notice by itself once it
     lands — no reload. (Contributed back from the crefo deployment.) */
  useEffect(() => {
    if (!user || activeKind !== "agent" || engineReady === true) return;
    let stop = false, t = null, tries = 0;
    const check = () => L.AgentsAPI.engine().then(r => {
      if (stop) return;
      setEngineReady(!!r.ready);
      if (!r.ready && r.docker && r.image === false && ++tries < 30) t = setTimeout(check, 5000);
    }).catch(() => { if (!stop) setEngineReady(false); });
    check();
    return () => { stop = true; if (t) clearTimeout(t); };
  }, [user, activeKind, engineReady]);

  /* --- auth check + load convs on mount --- */
  const checkAuth = () => {
    setRecheck(true);
    let leaving = false;
    return L.Auth.me().then(u => {
      setUnreachable(false);
      L.clearForcedReload();    /* reached it — a later failure may reload again */
      setUser(u);
      if (u) { loadConvs(); loadAgents(); loadApps(); }
    }).catch(err => {
      /* Only a request that never landed means unreachable; anything else the
         server actually said is a plain "not signed in". */
      if (!L.isTransportFailure(err)) { setUnreachable(false); setUser(null); return; }
      /* The app is on screen but the origin is not answering, which means a
         worker served it from cache and the browser never got to show why.
         Get out of the way rather than explain: drop the worker and reload, so
         the next navigation is a real request and the browser's own
         certificate dialog is what the user sees. */
      if (L.canForceNetworkReload()) { leaving = true; setReloading(true); L.forceNetworkReload(); return; }
      setUnreachable(true);
    }).finally(() => {
      if (leaving) return;      /* the page is going away — don't paint a screen */
      setAuthChecked(true);
      setRecheck(false);
    });
  };
  useEffect(() => { checkAuth(); }, []);

  /* Open the right conversation when a routine notification is tapped — whether
     the app was already open (SW postMessage) or launched fresh (#conv= hash). */
  useEffect(() => {
    const openConv = id => { if (id) { setActiveId(id); setNavOpen(false); setShowRoutines(false); loadConvs(); } };
    const m = (location.hash || "").match(/conv=([^&]+)/);
    if (m) { openConv(decodeURIComponent(m[1])); history.replaceState(null, "", location.pathname); }
    if (!("serviceWorker" in navigator)) return;
    const onMsg = e => { if (e.data && e.data.type === "open-conversation") openConv(e.data.convId); };
    navigator.serviceWorker.addEventListener("message", onMsg);
    return () => navigator.serviceWorker.removeEventListener("message", onMsg);
  }, []);

  const loadConvs = async () => {
    try {
      const apiConvs = await L.ConvsAPI.list();
      setConvs(apiConvs);
    } catch (e) {
      console.error(t("err.loadConvs"), e);
    } finally {
      setConvsLoaded(true);   /* gates reattach until the DB convs are in state */
    }
  };
  const loadAgents = async () => {
    /* List omits transcripts (kept light); preserve any already-loaded messages. */
    try {
      const list = await L.AgentsAPI.list();
      setAgents(prev => list.map(a => { const p = prev.find(x => x.id === a.id); return p && p.messages ? { ...a, messages: p.messages } : a; }));
    } catch (e) { console.error(t("err.loadAgents"), e); }
    finally { setAgentsLoaded(true); }
  };
  const loadApps = async () => {
    try { setApps(await L.AppsAPI.list()); }
    catch (e) { console.error(t("err.loadApps"), e); }
  };

  /* Background activity feed: powers the BACKGROUND tab's status lines, the
     tab badge, and orphan/resume banners. Polls faster while anything runs. */
  useEffect(() => {
    if (!user) return;
    let stop = false, timer = null;
    const tick = async () => {
      try {
        const a = await L.AgentsAPI.activity();
        if (!stop) setActivity(a);
        /* Poll faster while ANYTHING is running, not just your own work: the
           box indicator reports other people's turns, and deciding the cadence
           from your own runs alone left it up to 25s stale in both directions
           — showing a turn that had finished, and missing one that had not
           started. */
        const mineBusy = a && a.runs && a.runs.some(r => r.status === "running" || r.status === "queued");
        const busyAny = mineBusy || !!(a && a.box && a.box.total);
        if (!stop) timer = setTimeout(tick, busyAny ? 6000 : 25000);
      } catch { if (!stop) timer = setTimeout(tick, 30000); }
    };
    tick();
    return () => { stop = true; clearTimeout(timer); };
  }, [user, activityTick]);

  /* A routine changed (created/paused/retargeted/deleted): the server derives
     `background` from active agent routines + services, so refresh the agents
     list and activity — an agent may have just been promoted or demoted. */
  const onRoutinesChanged = () => { loadAgents(); setActivityTick(t => t + 1); };
  /* ONE routines panel for the whole app. Opening it from an agent just scopes
     the initial filter to that agent; it is the same list and the same editor
     the sidebar opens, so chat and agent schedules never drift apart. */
  const openRoutines = (agentId = null) => { setRoutinesFocus(agentId); setShowRoutines(true); setNavOpen(false); };
  const loadAgentFull = async id => {
    try {
      const full = await L.AgentsAPI.get(id);
      /* liveCanvases reset: the turn's canvases are now persisted on messages. */
      setAgents(as => as.some(a => a.id === id) ? as.map(a => a.id === id ? { ...a, ...full, liveCanvases: [] } : a) : [{ ...full, liveCanvases: [] }, ...as]);
    } catch (e) {
      /* Stale selection (agent deleted elsewhere): drop it and reset the view. */
      if (e && e.status === 404) {
        setAgents(as => as.filter(a => a.id !== id));
        setActiveId(cur => cur === id ? null : cur);
      } else console.error(t("err.loadAgent"), e);
    }
  };

  /* --- autoscroll while streaming --- */
  const scrollToBottom = useCallback(() => {
    const el = scrollRef.current; if (!el) return;
    el.scrollTop = el.scrollHeight;
  }, []);
  /* The chat transcript uses the SAME scroller as the agent view, handing it
     the App's own refs so the streaming callbacks below keep working against
     stickRef exactly as they did. Must stay above the auth early-returns — it
     is a hook. */
  const chatScroll = useTranscriptScroll(activeKind + ":" + activeId, scrollRef, stickRef);

  /* --- handlers --- */
  const handleLogin = async u => {
    setUser(u);
    await loadConvs();
    loadAgents(); loadApps();
  };

  const handleLogout = async () => {
    await L.Auth.logout();
    setUser(null);
    setConvs([]); setAgents([]); setApps([]);
    setActiveId(null); setActiveKind("chat"); setActiveAppId(null); setWarmApps_([]);
  };

  const newChat = () => { setActiveKind("chat"); setActiveId(null); setActiveAppId(null); setNavOpen(false); setError(null); };

  const selectEntry = (id, kind) => { setActiveKind(kind); setActiveId(id); setActiveAppId(null); setNavOpen(false); setError(null); if (kind === "agent" && !runsRef.current.has(id)) loadAgentFull(id); };

  /* --- apps: open / create / link agent / delete --- */
  const selectApp = (id, row) => {
    const app = row || apps.find(a => a.id === id);
    setActiveAppId(id); setNavOpen(false); setError(null);
    /* Focus the linked agent so the docked chat, busy state, stop and
       re-attach all point at the right thread. */
    if (app && app.agentId && agents.some(a => a.id === app.agentId)) {
      setActiveKind("agent"); setActiveId(app.agentId);
      if (!runsRef.current.has(app.agentId)) loadAgentFull(app.agentId);
    }
  };
  const createApp = async (template, global) => {
    setCreatingApp(template);
    try {
      const a = await L.AppsAPI.create({ template, ...(global ? { scope: "global" } : {}) });
      setShowAppPicker(false);
      await loadApps();
      selectApp(a.id, a);
    } catch (e) { setError((e && e.message) || t("app.createFailed")); }
    finally { setCreatingApp(null); }
  };
  const deleteApp = async id => {
    setApps(as => as.filter(a => a.id !== id));
    if (id === activeAppId) setActiveAppId(null);
    dropWarm(id);
    try { await L.AppsAPI.remove(id); }
    catch (e) { setError((e && e.message) || t("app.deleteFailed")); loadApps(); }
  };
  const askDeleteApp = id => {
    const a = (apps || []).find(x => x.id === id);
    if (!a) return;
    setConfirm({
      title: t("app.deleteTitle", { name: a.name || t("app.thisApp") }),
      confirmLabel: t("app.delete"),
      body: (<>
        <p>{t("app.deleteBody")}</p>
        {a.scope === "global" && <p className="warn-line">{tb("app.deleteGlobalWarn")}</p>}
      </>),
      onConfirm: () => deleteApp(id),
    });
  };
  /* User-only code lock: locked apps can't be rewritten/deleted by the agent. */
  /* Scope is admin-only and server-enforced; this just applies the answer and
     hands the warnings back to the control that asked. */
  const setAppScope = async (id, scope) => {
    try {
      const r = await L.AppsAPI.update(id, { scope });
      setApps(as => as.map(a => a.id === id ? { ...a, ...r } : a));
      loadApps();
      return r;
    } catch (e) { setError((e && e.message) || t("app.scopeFailed")); return null; }
  };
  const toggleAppLock = async locked => {
    if (!activeAppId) return;
    setApps(as => as.map(a => a.id === activeAppId ? { ...a, locked } : a));   /* optimistic */
    try { await L.AppsAPI.update(activeAppId, { locked }); }
    catch (e) { setApps(as => as.map(a => a.id === activeAppId ? { ...a, locked: !locked } : a)); setError((e && e.message) || t("app.lockFailed")); }
  };
  /* Dock an agent to the open app — or pass null to disconnect it. On a global
     app the link is per user (server-side link table), so this only ever
     changes what THIS user has docked. */
  const linkAppAgent = async (agentId) => {
    if (!activeAppId) return;
    try {
      await L.AppsAPI.update(activeAppId, { agentId: agentId || null });
      setApps(as => as.map(a => a.id === activeAppId ? { ...a, agentId: agentId || null } : a));
      if (agentId) {
        setActiveKind("agent"); setActiveId(agentId);
        if (!runsRef.current.has(agentId)) loadAgentFull(agentId);
      }
    } catch (e) { setError((e && e.message) || t("app.linkFailed")); }
  };
  const newAgentForApp = async () => {
    try {
      const a = await L.AgentsAPI.create({ name: ((activeApp && activeApp.name) || "App") + " agent", ...(await agentBackendSnapshot()) });
      await loadAgents();
      await linkAppAgent(a.id);
    } catch (e) { setError((e && e.message) || t("agent.createFailed")); }
  };
  /* An agent's app_* tool call landed (SSE): refresh the tabs; nudge the open
     app — state changes flow via the versioned poll, html rewrites reload. */
  const onAppEvent = e => {
    loadApps();
    if (!e) return;
    if (e.action === "state") setAppStateTick(t => t + 1);
    /* Only the rewritten app reloads; the other mounted ones keep their memory. */
    else if (e.action === "updated") setAppHtmlTicks(m => ({ ...m, [e.id || "*"]: (m[e.id || "*"] || 0) + 1 }));
    else if (e.action === "deleted") { setActiveAppId(cur => cur === e.id ? null : cur); dropWarm(e.id); }
  };

  /* Agents run on the admin-selected tool-capable backend (Settings → Agent
     engine); snapshot it at creation so the UI shows the right model, and the
     server re-pins every turn anyway. */
  const agentBackendSnapshot = async () => {
    try { const b = await L.AgentsAPI.backend(); return { model: b.model, endpoint: b.endpoint, url: b.url }; }
    catch {
      /* Offline fallback: this deployment's OWN first provider, never a
         hardcoded host — the server re-pins the real backend on the next turn
         regardless, so this only decides the label shown until then. */
      const ep = (L.ENDPOINTS || [])[0];
      if (!ep) return {};
      return { model: (ep.models[0] || {}).id, endpoint: ep.name, url: ep.base + ep.path };
    }
  };
  const newAgent = async () => {
    setError(null);
    try {
      /* Placeholder name — the server auto-names it after the first turn. */
      const a = await L.AgentsAPI.create({ name: "New agent", ...(await agentBackendSnapshot()) });
      await loadAgents();
      setActiveKind("agent"); setActiveId(a.id); setActiveAppId(null); setNavOpen(false);
    } catch (e) { setError((e && e.message) || "Couldn't create agent"); }
  };

  const deleteConv = async id => {
    setConvs(cs => cs.filter(c => c.id !== id));
    if (id === activeId && activeKind === "chat") setActiveId(null);
    try { await L.ConvsAPI.delete(id); } catch (e) { console.error("Delete failed:", e); }
  };
  const deleteAgent = async id => {
    setAgents(as => as.filter(a => a.id !== id));
    if (id === activeId && activeKind === "agent") { setActiveId(null); setActiveKind("chat"); }
    /* The server has the last word (it refuses the wiki maintainer), so put the
       row back and say why rather than leaving the sidebar lying. */
    try { await L.AgentsAPI.remove(id); }
    catch (e) { setError((e && e.message) || t("agent.deleteFailed")); loadAgents(); }
  };
  /* Deleting an agent is not just a row: its workspace volume, its services and
     its loops go with it. Spell that out before doing it. */
  const askDeleteAgent = id => {
    const a = (agents || []).find(x => x.id === id);
    if (!a) return;
    const svc   = ((activity && activity.services)  || []).filter(s => s.agentId === id && s.status === "running").length;
    const loops = ((activity && activity.schedules) || []).filter(s => s.agentId === id && s.enabled).length;
    setConfirm({
      title: t("agent.deleteTitle", { name: a.name || t("agent.thisAgent") }),
      confirmLabel: t("agent.delete"),
      body: (<>
        <p>{t("agent.deleteBody")}</p>
        {(svc > 0 || loops > 0) && (
          <p className="warn-line">
            {svc > 0 && <>{t("agent.deleteSvcWarn", { n: svc })} </>}
            {loops > 0 && <>{t("agent.deleteLoopWarn", { n: loops })}</>}
          </p>
        )}
        {a.scope === "global" && <p className="warn-line">{tb("agent.deleteGlobalWarn")}</p>}
      </>),
      onConfirm: () => deleteAgent(id),
    });
  };
  const renameAgent = async (id, name) => {
    setAgents(as => as.map(a => a.id === id ? { ...a, name } : a));
    try { await L.AgentsAPI.update(id, { name }); } catch (e) { console.error("Rename agent failed:", e); }
  };
  /* Which engine this agent runs on. null = follow the deployment default.
     The server returns the refreshed row, so the model and endpoint the
     transcript labels itself with move at the same time. */
  /* `patch` is {backend} or {model} — the two halves of an agent's engine. */
  const setAgentBackend = async (id, patch) => {
    const optimistic = "backend" in patch ? { backend: patch.backend, modelPin: null } : { modelPin: patch.model };
    setAgents(as => as.map(a => a.id === id ? { ...a, ...optimistic } : a));
    try {
      const r = await L.AgentsAPI.update(id, patch);
      setAgents(as => as.map(a => a.id === id ? { ...a, ...r } : a));
    } catch (e) { setError((e && e.message) || "Couldn't change the engine"); loadAgents(); }
  };

  /* Run an agent turn (pi in its container). Streams pi's tool steps + answer
     into the agent's transcript via the same durable-run plumbing as chat. */
  const agentSend = async (agent, text, files, priv, flags) => {
    const t = (text || "").trim();
    const atts = Array.isArray(files) ? files : [];
    if ((!t && !atts.length) || busy) return;
    const redact = priv ? L.privacyMode() : "off";
    setError(null);
    const cid = agent.id;
    const now = Date.now();

    /* The model prompt prepends each uploaded file's extracted text (capped) and
       notes that the real file lives in the workspace; the displayed user turn
       stays clean (text + attachment chips). */
    const CAP = 12000;
    const blocks = atts.map(f => {
      const body = (f.text || "").slice(0, CAP);
      const more = (f.text || "").length > CAP ? "\n…(truncated — read the full file at " + f.path + " in your workspace)" : "";
      return "--- Uploaded file: " + f.path + " (saved in your workspace) ---\n" + (body || "(binary or empty — read it from the workspace)") + more + "\n--- end of " + f.path + " ---";
    }).join("\n\n");
    const prompt = [blocks, t].filter(Boolean).join("\n\n") || t;
    const attachMeta = atts.map(f => ({ name: f.name, size: f.size }));

    const userMsg = { role: "user", content: t, ts: now, attachments: attachMeta.length ? attachMeta : undefined };
    const botMsg  = { role: "assistant", content: "", ts: now, model: agent.model };
    setAgents(as => as.map(a => a.id === cid ? { ...a, messages: [...(a.messages || []), userMsg, botMsg], updated: now } : a));
    stickRef.current = true; requestAnimationFrame(scrollToBottom);
    setRunning(cid, true);

    let acc = "", accR = "", steps = [];
    const patchBot = patch => setAgents(as => as.map(a => {
      if (a.id !== cid) return a;
      const m = (a.messages || []).slice();
      for (let i = m.length - 1; i >= 0; i--) { if (m[i].role === "assistant") { m[i] = { ...m[i], ...patch }; break; } }
      return { ...a, messages: m, updated: Date.now() };
    }));
    const stick = () => { if (cid === activeId && stickRef.current) requestAnimationFrame(scrollToBottom); };

    const controller = new AbortController();
    runsRef.current.set(cid, { runId: null, ctrl: controller });
    let runId;
    /* If this agent is docked beside an open app, tell the run which one so
       the model's app_* tools target it (the app row's `locked` flag decides
       whether code edits are allowed — server-enforced). */
    const appId = (activeApp && activeApp.agentId === cid) ? activeApp.id : undefined;
    try { const r = await L.AgentsAPI.run(cid, prompt, L.enabledSkills(), L.enabledMcpServers(),
      { userText: t, attachments: attachMeta, redact, appId,
        useWiki: !!(flags && flags.useWiki), useTools: !(flags && flags.useTools === false) }); runId = r.runId;
      /* The server says which model this turn runs on; our copy of the agent may predate a change of engine. */
      if (r.model) { patchBot({ model: r.model }); setAgents(as => as.map(a => a.id === cid ? { ...a, model: r.model } : a)); } }
    catch (e) { setRunning(cid, false); runsRef.current.delete(cid); setError((e && e.message) || "Couldn't start the agent."); return; }
    runsRef.current.set(cid, { runId, ctrl: controller });

    const finish = () => { setRunning(cid, false); setReconn(cid, false); runsRef.current.delete(cid); };
    await L.attachRun(runId, {
      signal: controller.signal,
      onTool: e => { steps = [...steps, { name: e.name, arguments: e.arguments, result: null }]; patchBot({ toolSteps: steps, queuedPos: undefined }); stick(); },
      onToolResult: e => { for (let i = steps.length - 1; i >= 0; i--) { if (steps[i].name === e.name && steps[i].result === null) { steps[i] = { ...steps[i], result: e.result, isError: e.isError }; break; } } steps = [...steps]; patchBot({ toolSteps: steps }); },
      onToken: tok => { acc += tok; patchBot({ content: acc, queuedPos: undefined }); stick(); },
      onReasoning: r => { accR += r; patchBot({ reasoning: accR, queuedPos: undefined }); },
      onQueued: pos => patchBot({ queuedPos: pos }),
      onReplay: () => { acc = ""; accR = ""; steps = []; patchBot({ content: "", reasoning: undefined, toolSteps: undefined }); },
      onRedaction: e => patchBot({ privacy: true, redactions: e.count, redactionTypes: e.types }),
      onCanvas: c => setAgents(as => as.map(a => a.id === cid ? { ...a, liveCanvases: [...(a.liveCanvases || []), c] } : a)),
      onCompaction: e => patchBot(e.phase === "start"
        ? { compacting: true }
        : { compacting: false, compactions: e.aborted ? undefined : [{ reason: e.reason, tokensBefore: e.tokensBefore }] }),
      onContext: e => patchBot({ context: { used: e.used, window: e.window, percent: e.percent } }),
      onApp: onAppEvent,
      onRenamed: name => setAgents(as => as.map(a => a.id === cid ? { ...a, name } : a)),
      onReconnecting: () => setReconn(cid, true),
      onReconnected: () => setReconn(cid, false),
      onFinal: txt => { acc = txt || acc; patchBot({ content: acc, queuedPos: undefined }); },
      onStopped: () => { patchBot({ stopped: true, queuedPos: undefined }); finish(); },
      onError: err => { finish(); if (err && err.name === "AbortError") return; setError((err && err.message) || "Agent run failed"); },
      onDone: () => { finish(); loadAgentFull(cid); setActivityTick(t => t + 1); },
    });
  };

  /* --- send message --- */
  const send = async (textRaw, opts = {}) => {
    setError(null);
    const fileList   = opts.files || [];
    /* A file can give us BOTH: an image carries its picture and whatever OCR
       read off it, and a scanned PDF with no text at all still hands over page
       images. So these are two independent harvests over the same list, not a
       split into two kinds of file. */
    const textFiles  = fileList.filter(f => f.text);
    const attachMeta = textFiles.map(f => ({ name: f.name, size: f.size, type: f.type }));
    const images     = fileList.flatMap(f => [
      ...(f.dataUrl ? [{ name: f.name, dataUrl: f.dataUrl }] : []),
      ...((f.pages || []).map((u, i) => ({ name: f.name + " (page " + (i + 1) + ")", dataUrl: u }))),
    ]);

    /* Treat an activeId that isn't in the loaded list (e.g. a stale/deleted id
       persisted in localStorage) as a fresh conversation. */
    const existing = activeId ? convs.find(c => c.id === activeId) : null;
    let convId  = existing ? activeId : null;
    const isNew = !convId;

    /* Capture prior messages now, before any state mutations */
    const priorMessages = existing ? (existing.messages || []) : [];

    /* Web search now runs server-side (the run/routine paths share it), so the
       key never reaches the client and routines can search too. We just pass a
       flag and mark the message when the server emits a `searched` event. */
    const doSearch  = !!opts.search;
    const privacy   = !!opts.privacy;
    const redact    = privacy ? L.privacyMode() : "off";
    const userMsg   = { role: "user", content: textRaw, ts: Date.now(), attachments: attachMeta, images, privacy };
    const convTitle = (textRaw || (attachMeta[0] && attachMeta[0].name) || (images[0] && images[0].name) || "New chat").slice(0, 48).replace(/\n/g, " ");

    /* Create or update conv in local state */
    if (!convId) {
      convId = L.uid();
      setConvs(cs => [{ id: convId, title: convTitle, created: Date.now(), updated: Date.now(), messages: [userMsg] }, ...cs]);
      setActiveId(convId);
    } else {
      setConvs(cs => cs.map(c => c.id === convId
        ? { ...c, updated: Date.now(), messages: [...c.messages, userMsg] }
        : c));
    }

    const botTs  = Date.now();
    const botMsg = { role: "assistant", content: "", ts: botTs, model };
    setConvs(cs => cs.map(c => c.id === convId
      ? { ...c, messages: [...c.messages, botMsg] }
      : c));

    stickRef.current = true;
    requestAnimationFrame(scrollToBottom);

    /* Text file contents (MarkItDown) become context; images go to the vision
       path. apiContent() returns a plain string when there are no images, or an
       OpenAI vision content array (text + image_url parts) when there are. */
    const apiContent = (textContent, imgs) => {
      if (!imgs || !imgs.length) return textContent;
      const arr = [];
      if (textContent) arr.push({ type: "text", text: textContent });
      for (const im of imgs) arr.push({ type: "image_url", image_url: { url: im.dataUrl } });
      return arr;
    };
    const fileBlocks = textFiles
      .map(f => "--- Attached file: " + f.name + " (converted to Markdown) ---\n"
              + f.text
              + "\n--- end of " + f.name + " ---")
      .join("\n\n");
    const parts = [];
    if (fileBlocks) parts.push(fileBlocks);
    if (textRaw)    parts.push(textRaw);
    const combinedText = parts.join("\n\n");

    /* Build API payload (vision-aware; prior turns keep their images too) */
    const payload = [...priorMessages, userMsg]
      .filter(m => m.content !== "" || (m.images && m.images.length) || m.role === "user")
      .map(m => ({ role: m.role, content: apiContent(m === userMsg ? combinedText : m.content, m.images) }));

    /* Teach the model the file-block protocol when "Make file" is on. Remember
       the setting so regenerate/edit re-run with the same capability. */
    filegenRef.current = !!opts.filegen;
    if (opts.filegen) payload.unshift({ role: "system", content: L.filegenSystemPrompt() });

    const controller = new AbortController();
    runsRef.current.set(convId, { runId: null, ctrl: controller });
    setRunning(convId, true);

    const cid = convId; /* capture for closure */
    let acc = "";
    let accR = "";   /* accumulated reasoning (reasoning_content) for this turn */
    let served = { endpoint: null, model: null }; /* the provider + model that ACTUALLY answered */
    let mcpSteps = []; /* MCP tool-call steps for this turn */

    const patchBot = patch => setConvs(cs => cs.map(c => {
      if (c.id !== cid) return c;
      const msgs = c.messages.slice();
      for (let i = msgs.length - 1; i >= 0; i--) {
        if (msgs[i].role === "assistant") { msgs[i] = { ...msgs[i], ...patch }; break; }
      }
      return { ...c, messages: msgs, updated: Date.now() };
    }));

    const dropEmptyBot = () => setConvs(cs => cs.map(c => {
      if (c.id !== cid) return c;
      const msgs = c.messages.slice();
      const last = msgs[msgs.length - 1];
      if (last && last.role === "assistant" && !last.content && !(last.toolSteps || []).length && !last.reasoning) msgs.pop();
      return { ...c, messages: msgs };
    }));

    /* Pick the model endpoint (selected provider first; must be a valid URL). */
    const isHttp = u => { try { return /^https?:$/.test(new URL(u).protocol); } catch { return false; } };
    const provs = L.providersList();
    const selName = L.getProvider();
    const ep = (selName && provs.find(e => e.name === selName && isHttp((e.base || "") + (e.path || ""))))
            || provs.find(e => isHttp((e.base || "") + (e.path || "")));
    if (!ep) { setRunning(cid, false); runsRef.current.delete(cid); setError("No valid model endpoint — check Connection settings."); dropEmptyBot(); return; }
    served.endpoint = ep.name;
    patchBot({ endpoint: ep.name });

    /* Start a DURABLE server-side run, then attach to its event stream. The run
       keeps generating and persists the answer itself even if we disconnect, so
       closing the tab / switching windows / a dropped network never loses it. */
    let runId = null;
    try {
      const r = await L.startChatRun({
        url: ep.base + ep.path, model, messages: payload, redact,
        servers: L.enabledMcpServers(), mcp: !!opts.mcp, search: doSearch,
        skills: opts.mcp ? L.enabledSkills() : [],
        convId: cid, isNew, title: convTitle,
        persistMessages: [...priorMessages, userMsg],
        endpoint: ep.name, assistantTs: botTs,
      });
      runId = r.runId;
    } catch (e) {
      setRunning(cid, false); runsRef.current.delete(cid);
      setError((e && e.message) || "Couldn't start the request.");
      dropEmptyBot();
      return;
    }
    runsRef.current.set(cid, { runId, ctrl: controller });
    setEndpoint(ep.name);

    await L.attachRun(runId, {
      signal: controller.signal,
      onTool: e => {
        mcpSteps = [...mcpSteps, { name: e.name, arguments: e.arguments, result: null }];
        patchBot({ toolSteps: mcpSteps });
        if (stickRef.current) requestAnimationFrame(scrollToBottom);
      },
      onToolResult: e => {
        for (let i = mcpSteps.length - 1; i >= 0; i--) {
          if (mcpSteps[i].name === e.name && mcpSteps[i].result === null) {
            mcpSteps[i] = { ...mcpSteps[i], result: e.result, isError: e.isError }; break;
          }
        }
        mcpSteps = [...mcpSteps];
        patchBot({ toolSteps: mcpSteps });
      },
      onToken: t => { acc += t; patchBot({ content: acc }); if (stickRef.current) requestAnimationFrame(scrollToBottom); },
      onReasoning: r => { accR += r; patchBot({ reasoning: accR }); if (stickRef.current) requestAnimationFrame(scrollToBottom); },
      onReset: () => { acc = ""; patchBot({ content: "" }); },   /* mid-answer drop: clear partial, the retry re-streams */
      onSearched: () => patchBot({ searched: true }),
      onRedaction: e => patchBot({ privacy: true, redactions: e.count, redactionTypes: e.types }),
      onReplay: () => { acc = ""; accR = ""; mcpSteps = []; patchBot({ content: "", reasoning: undefined, toolSteps: undefined, searched: undefined }); },
      onReconnecting: () => setReconn(cid, true),
      onReconnected: () => setReconn(cid, false),
      onServed: m => { served.model = m; patchBot({ servedModel: m }); },
      onFinal: txt => { acc = txt || acc; patchBot({ content: acc }); },
      onStopped: () => { patchBot({ stopped: true }); /* keep whatever streamed */ },
      onGone: () => setError("This answer expired before it could be recovered. Please send it again."),
      onError: err => {
        setRunning(cid, false); setReconn(cid, false); runsRef.current.delete(cid);
        if (err && err.name === "AbortError") return;   /* we detached on purpose */
        setError((err && err.message) || "Request failed");
      },
      onDone: () => {
        setRunning(cid, false); setReconn(cid, false); runsRef.current.delete(cid);
        setEndpoint(served.endpoint);
        if (!acc && !accR && !mcpSteps.length) dropEmptyBot();   /* server persisted; nothing came back */
      },
    });
  };

  /* Stop is a deliberate cancel of the ACTIVE entry's run: abort our view AND
     tell the server to end it (closing the tab leaves it running in the bg). */
  const stop = () => {
    const r = runsRef.current.get(activeId);
    if (r) { if (r.runId) L.stopRun(r.runId); if (r.ctrl) r.ctrl.abort(); runsRef.current.delete(activeId); }
    setRunning(activeId, false); setReconn(activeId, false);
    /* Keep the partial answer/reasoning and flag it stopped (server persists the
       same, so it survives a reload; the next turn keeps this as context). */
    const markStopped = list => list.map(x => {
      if (x.id !== activeId) return x;
      const msgs = (x.messages || []).slice();
      for (let i = msgs.length - 1; i >= 0; i--) { if (msgs[i].role === "assistant") { msgs[i] = { ...msgs[i], stopped: true }; break; } }
      return { ...x, messages: msgs };
    });
    if (activeKind === "agent") setAgents(markStopped); else setConvs(markStopped);
  };

  /* Re-attach to a still-running background run when an entry is opened (after a
     reload, a new window, another device, or just navigating back to it). Works
     for BOTH chats and agents. The server replays the run's full event buffer,
     so we rebuild the live answer and tail it. If we're already streaming this
     entry in-process (runsRef has it), we skip — no double attach. */
  useEffect(() => {
    if (!user || !activeId) return;
    const cid = activeId, kind = activeKind;
    if (kind === "chat" ? !convsLoaded : !agentsLoaded) return;
    if (runsRef.current.has(cid)) return;   /* already streaming in-process */
    const ctrl = new AbortController();
    let cancelled = false;
    (async () => {
      if (kind === "agent") { try { await loadAgentFull(cid); } catch {} }   /* ensure transcript is present */
      if (cancelled || runsRef.current.has(cid)) return;
      const runId = await L.activeRun(cid);
      if (cancelled || !runId || runsRef.current.has(cid)) return;
      runsRef.current.set(cid, { runId, ctrl });
      setRunning(cid, true);
      const store = kind === "agent" ? setAgents : setConvs;
      /* Ensure a trailing assistant bubble exists, cleared so the replay rebuilds it. */
      store(list => list.map(x => {
        if (x.id !== cid) return x;
        const msgs = (x.messages || []).slice();
        const last = msgs[msgs.length - 1];
        if (!last || last.role !== "assistant") msgs.push({ role: "assistant", content: "", ts: Date.now(), model });
        else msgs[msgs.length - 1] = { ...last, content: "", reasoning: undefined, toolSteps: undefined };
        return { ...x, messages: msgs };
      }));
      let acc = "", accR = "", steps = [];
      const patchBot = patch => store(list => list.map(x => {
        if (x.id !== cid) return x;
        const msgs = (x.messages || []).slice();
        for (let i = msgs.length - 1; i >= 0; i--) { if (msgs[i].role === "assistant") { msgs[i] = { ...msgs[i], ...patch }; break; } }
        return { ...x, messages: msgs, updated: Date.now() };
      }));
      const cleanup = () => { setRunning(cid, false); setReconn(cid, false); const cur = runsRef.current.get(cid); if (cur && cur.runId === runId) runsRef.current.delete(cid); };
      await L.attachRun(runId, {
        signal: ctrl.signal,
        onTool: e => { steps = [...steps, { name: e.name, arguments: e.arguments, result: null }]; patchBot({ toolSteps: steps }); },
        onToolResult: e => { for (let i = steps.length - 1; i >= 0; i--) { if (steps[i].name === e.name && steps[i].result === null) { steps[i] = { ...steps[i], result: e.result, isError: e.isError }; break; } } steps = [...steps]; patchBot({ toolSteps: steps }); },
        onToken: t => { acc += t; patchBot({ content: acc }); },
        onReasoning: r => { accR += r; patchBot({ reasoning: accR }); },
        onReset: () => { acc = ""; patchBot({ content: "" }); },
        onSearched: () => patchBot({ searched: true }),
        onRedaction: e => patchBot({ privacy: true, redactions: e.count, redactionTypes: e.types }),
        onCanvas: c => { if (kind === "agent") setAgents(as => as.map(a => a.id === cid ? { ...a, liveCanvases: [...(a.liveCanvases || []), c] } : a)); },
        onCompaction: e => patchBot(e.phase === "start"
          ? { compacting: true }
          : { compacting: false, compactions: e.aborted ? undefined : [{ reason: e.reason, tokensBefore: e.tokensBefore }] }),
        onContext: e => patchBot({ context: { used: e.used, window: e.window, percent: e.percent } }),
        onApp: onAppEvent,
        onQueued: pos => patchBot({ queuedPos: pos }),
        onRenamed: name => { if (kind === "agent") setAgents(as => as.map(a => a.id === cid ? { ...a, name } : a)); },
        onReplay: () => { acc = ""; accR = ""; steps = []; patchBot({ content: "", reasoning: undefined, toolSteps: undefined, searched: undefined }); },
        onReconnecting: () => setReconn(cid, true),
        onReconnected: () => setReconn(cid, false),
        onServed: m => patchBot({ servedModel: m }),
        onFinal: txt => { acc = txt || acc; patchBot({ content: acc }); },
        onStopped: () => { patchBot({ stopped: true }); cleanup(); },
        onError: () => { cleanup(); },
        onDone: () => { cleanup(); if (kind === "agent") loadAgentFull(cid); },
      });
    })();
    return () => { cancelled = true; ctrl.abort(); };
  }, [user, activeId, activeKind, convsLoaded, agentsLoaded]);

  /* ---- edit & regenerate ---- */
  /* OpenAI content for a stored message: string, or vision array if it has images */
  const apiContentFor = m => {
    if (!m.images || !m.images.length) return m.content;
    const arr = [];
    if (m.content) arr.push({ type: "text", text: m.content });
    for (const im of m.images) arr.push({ type: "image_url", image_url: { url: im.dataUrl } });
    return arr;
  };

  /* Re-run the assistant from a base message list (ending in a user message).
     Replaces the trailing assistant turn. Shared by regenerate + edit. */
  const streamReply = async (cid, baseMessages, redact) => {
    setError(null);
    const botTs  = Date.now();
    const botMsg = { role: "assistant", content: "", ts: botTs, model };
    setConvs(cs => cs.map(c => c.id === cid ? { ...c, messages: [...baseMessages, botMsg], updated: Date.now() } : c));
    stickRef.current = true; requestAnimationFrame(scrollToBottom);

    const payload = baseMessages
      .filter(m => m.content !== "" || (m.images && m.images.length) || m.role === "user")
      .map(m => ({ role: m.role, content: apiContentFor(m) }));
    if (filegenRef.current) payload.unshift({ role: "system", content: L.filegenSystemPrompt() });

    const controller = new AbortController(); runsRef.current.set(cid, { runId: null, ctrl: controller }); setRunning(cid, true);
    let acc = ""; let accR = ""; let served = { endpoint: null, model: null };

    const patchBot = patch => setConvs(cs => cs.map(c => {
      if (c.id !== cid) return c;
      const msgs = c.messages.slice();
      for (let i = msgs.length - 1; i >= 0; i--) if (msgs[i].role === "assistant") { msgs[i] = { ...msgs[i], ...patch }; break; }
      return { ...c, messages: msgs, updated: Date.now() };
    }));
    const flush = () => patchBot({ content: acc });
    const persist = () => {
      if (!acc) return;
      const finalMsgs = [...baseMessages, { role: "assistant", content: acc, ts: botTs, model, endpoint: served.endpoint, servedModel: served.model || model, reasoning: accR || undefined }];
      L.ConvsAPI.update(cid, { messages: finalMsgs }).catch(e => console.error("Update conv failed:", e));
    };

    await L.streamChat({
      model, messages: payload, redact, signal: controller.signal,
      onToken: t => { acc += t; flush(); },
      onReasoning: r => { accR += r; patchBot({ reasoning: accR }); if (stickRef.current) requestAnimationFrame(scrollToBottom); },
      onDone: info => {
        const ep = info && info.endpoint; const sm = (info && info.model) || null;
        served = { endpoint: ep, model: sm };
        setEndpoint(ep); setRunning(cid, false); runsRef.current.delete(cid);
        setConvs(cs => cs.map(c => {
          if (c.id !== cid) return c;
          const msgs = c.messages.slice();
          for (let i = msgs.length - 1; i >= 0; i--) if (msgs[i].role === "assistant") { msgs[i] = { ...msgs[i], content: acc, endpoint: ep, servedModel: sm || msgs[i].model }; break; }
          return { ...c, messages: msgs };
        }));
        persist();
      },
      onError: err => {
        setRunning(cid, false); runsRef.current.delete(cid);
        if (err && err.name === "AbortError") { persist(); return; }
        setError((err && err.message) || "Request failed");
        if (!acc) setConvs(cs => cs.map(c => {
          if (c.id !== cid) return c;
          const msgs = c.messages.slice();
          if (msgs.length && msgs[msgs.length - 1].role === "assistant" && !msgs[msgs.length - 1].content) msgs.pop();
          return { ...c, messages: msgs };
        }));
      },
    });
  };

  const regenerateLast = cid => {
    if (busy) return;
    const conv = convs.find(c => c.id === cid); if (!conv) return;
    const msgs = conv.messages;
    let cut = msgs.length;
    while (cut > 0 && msgs[cut - 1].role === "assistant") cut--;   /* drop trailing assistant turn */
    if (cut === 0) return;
    const base = msgs.slice(0, cut);
    const lastUser = [...base].reverse().find(m => m.role === "user");
    streamReply(cid, base, lastUser && lastUser.privacy ? L.privacyMode() : "off");
  };

  const editAndResend = (cid, index, newText) => {
    if (busy) return;
    const conv = convs.find(c => c.id === cid); if (!conv) return;
    const msgs = conv.messages;
    if (index < 0 || index >= msgs.length || msgs[index].role !== "user") return;
    const editedUser = { ...msgs[index], content: newText };
    const base = [...msgs.slice(0, index), editedUser];
    streamReply(cid, base, editedUser.privacy ? L.privacyMode() : "off");
  };

  /* ---- render ---- */

  /* About to reload past the cached app — say nothing, the browser is next. */
  if (reloading) return <ReloadingScreen />;

  /* Show loading until auth is verified to avoid flash of wrong screen */
  if (!authChecked) {
    return (
      <div style={{ height: "100%", display: "grid", placeItems: "center", background: "var(--canvas)" }}>
        <span style={{ fontFamily: "var(--mono)", fontSize: 11, color: "var(--ink-4)", letterSpacing: "0.1em", textTransform: "uppercase" }}>
          {t("common.loading")}
        </span>
      </div>
    );
  }

  /* Unreachable outranks "no user": the cached user is still valid, we simply
     cannot confirm it, and offering a sign-in form that cannot leave the
     browser is what made an expired certificate read as a bad password. */
  if (unreachable) return <UnreachableScreen offline={navigator.onLine === false} onRetry={checkAuth} retrying={recheck} />;

  if (!user) return <LoginScreen onLogin={handleLogin} />;

  const msgs = active ? active.messages : [];

  return (
    <div className={"app " + (navOpen ? "nav-open" : "")}
         style={{
           "--side-w": panes.side ? panes.side + "px" : undefined,
           "--apps-h": panes.apps ? panes.apps + "px" : undefined,
           "--chat-w": panes.chat ? panes.chat + "px" : undefined,
         }}>
      <div className="scrim" onClick={() => setNavOpen(false)} />
      <Sidebar
        convs={convs} agents={agents} activeId={activeId} activeKind={activeKind}
        onSelect={selectEntry}
        onNew={newChat} onNewAgent={newAgent} onDelete={deleteConv} onDeleteAgent={askDeleteAgent}
        apps={apps} activeAppId={activeAppId} onSelectApp={selectApp}
        onNewApp={() => { setShowAppPicker(true); setNavOpen(false); }} onDeleteApp={askDeleteApp}
        user={user} onLogout={handleLogout}
        onRoutines={() => openRoutines(null)}
        activity={activity} sideTab={sideTab} onSideTab={setSideTab}
        panes={panes} onPane={onPane}
      />
      <div className="main">
        {/* Every recently opened app keeps its pane; only the active one is
            shown, the rest sit hidden with their iframe (and its memory,
            state poll and WebMCP bridge) intact. The docked chat is rendered
            for the active app alone: it follows the active agent. */}
        {warmApps.map(a => {
          const on = !!activeApp && a.id === activeApp.id;
          const ag = a.agentId ? (agents.find(x => x.id === a.agentId) || null) : null;
          return (
          <div key={a.id} className="appws" hidden={!on} ref={on ? wsRef : null}>
            <AppWorkspace app={a} busy={!!(ag && runningIds[ag.id])}
                          appStateTick={appStateTick}
                          appHtmlTick={(appHtmlTicks[a.id] || 0) + (appHtmlTicks["*"] || 0)}
                          locked={!!a.locked} onLock={toggleAppLock}
                          onMenu={() => setNavOpen(true)}
                          agents={agents} dockedAgent={ag}
                          onLinkAgent={linkAppAgent} onNewAgent={newAgentForApp}
                          isAdmin={isAdmin} onSetScope={scope => setAppScope(a.id, scope)}
                          box={activity && activity.box}
                          onAsk={ag ? (text => agentSend(ag, text, [], false)) : null} />
            {on && (<>
            <Splitter axis="v" label={t("split.appAgent")}
                      onDrag={x => dragChat(x)}
                      onNudge={d => {
                        const r = wsRef.current && wsRef.current.getBoundingClientRect();
                        if (r) dragChat(r.right - ((panes.chat || Math.round(r.width * 0.3)) + d));
                      }}
                      onReset={() => onPane("chat", null)} />
            <div className="appws-chat">
              {appAgent ? (
                <AgentView box={activity && activity.box} agent={appAgent} onMenu={() => setNavOpen(true)} onRename={renameAgent} onSetBackend={setAgentBackend}
                           busy={busy} reconnecting={reconnecting} engineReady={engineReady}
                           orphan={orphanFor(appAgent.id)} onDismissOrphan={dismissOrphan}
                           onOpenRoutines={openRoutines}
                           onSend={(text, files, priv, flags) => agentSend(appAgent, text, files, priv, flags)} onStop={stop} />
              ) : (
                <AppAgentPicker agents={agents} onPick={linkAppAgent} onNew={newAgentForApp} />
              )}
            </div>
            </>)}
          </div>
          );
        })}
        {activeApp ? null : activeKind === "agent" && activeAgent ? (
          <AgentView box={activity && activity.box} agent={activeAgent} onMenu={() => setNavOpen(true)} onRename={renameAgent} onSetBackend={setAgentBackend}
                     busy={busy} reconnecting={reconnecting} engineReady={engineReady}
                     orphan={orphanFor(activeAgent.id)} onDismissOrphan={dismissOrphan}
                     onOpenRoutines={openRoutines}
                     onSend={(text, files, priv, flags) => agentSend(activeAgent, text, files, priv, flags)} onStop={stop} />
        ) : (<>
        <div className="topbar">
          <div style={{ display: "flex", alignItems: "center", minWidth: 0 }}>
            <button className="menu-btn" onClick={() => setNavOpen(true)}><MenuIcon /></button>
            <div className="topbar-title">
              {active
                ? <><span className="tt-kind">{t("nav.chat")}</span><span className="tt-name" title={active.title}>{active.title}</span></>
                : <><span className="tt-kind">{t("topbar.new")}</span><span className="tt-name">{t("topbar.startConversation")}</span></>}
            </div>
          </div>
          <div className="tools">
            <BoxBusy box={activity && activity.box} />
            <ProviderSelect
              provider={provider} live={endpoint}
              setProvider={name => { L.setProvider(name); setProviderState(name); }} />
            <ModelSelect model={model} setModel={setModel} models={providerModels}
                         base={(L.providersList().find(p => p.name === curProvider) || {}).base} />
            <a className="icon-btn" title={t("nav.guideApps")} href="/help/apps.de.html" target="_blank" rel="noopener"><HelpIcon /></a>
            <a className="icon-btn" title={t("nav.settings")} href="/settings.html"><GearIcon /></a>
          </div>
        </div>

        <div className="scroll-wrap">
        <div className="scroll" ref={chatScroll.ref}>
          {msgs.length === 0
            ? <Empty onPick={t => setPreset(t)} />
            : (
              <div className="thread">
                {msgs.map((m, i) => {
                  const requestedId    = m.model || model;
                  const servedId       = m.servedModel || requestedId;
                  const known          = L.findModel(servedId);
                  const modelLabel     = known ? known.label : servedId; /* raw id when off-catalog */
                  const reqKnown       = L.findModel(requestedId);
                  const requestedLabel = reqKnown ? reqKnown.label : requestedId;
                  /* Warn only on a GENUINE substitution. Normalise by dropping
                     provider prefixes + separators so "privatemode/gpt-oss-120b"
                     == "openai/gpt-oss-120b". Never warn for aliases that resolve
                     to a concrete model by design: router/* routers, any "*latest"
                     pointer (e.g. kimi-latest -> kimi-k2.6), or a model flagged
                     alias:true in the catalog. */
                  const norm = s => String(s || "").toLowerCase().split("/").pop().replace(/[\s._-]/g, "");
                  const isAlias = /^router\//i.test(requestedId)
                               || /latest/i.test(requestedId)
                               || !!(reqKnown && reqKnown.alias);
                  const mismatch = !!m.servedModel && !isAlias && norm(m.servedModel) !== norm(requestedId);
                  return (
                    <Message key={i} m={m}
                             modelLabel={modelLabel}
                             provider={L.providerLabel(m.endpoint)}
                             requestedLabel={requestedLabel}
                             mismatch={mismatch}
                             isLast={i === msgs.length - 1}
                             busy={busy}
                             onEdit={m.role === "user" ? (text => editAndResend(active.id, i, text)) : undefined}
                             onRegenerate={m.role === "assistant" ? (() => regenerateLast(active.id)) : undefined}
                             streaming={busy && i === msgs.length - 1 && m.role === "assistant"} />
                  );
                })}
              </div>
            )}
        </div>
        <ScrollDown show={chatScroll.away} onClick={chatScroll.toBottom} />
        </div>

        {reconnecting && (
          <div style={{ padding: "0 24px" }}>
            <div className="reconnect-banner">
              <span className="rec-dot busy" /> {t("chat.reconnecting")}
            </div>
          </div>
        )}
        {error && (
          <div style={{ padding: "0 24px" }}>
            <div className="banner">
              <span className="sq" style={{ width: 8, height: 8, background: "var(--maroon)" }} />
              {error}
              <a className="act-btn" href="/settings.html"
                 style={{ marginLeft: "auto", color: "var(--maroon)", borderColor: "var(--terra-line)", textDecoration: "none" }}>{t("nav.connection")}</a>
              <button className="x" onClick={() => setError(null)}>✕</button>
            </div>
          </div>
        )}

        <Composer onSend={send} onStop={stop} busy={busy} mcpAvailable={mcpAvail}
                  presetText={preset} clearPreset={() => setPreset("")} />
        </>)}
      </div>
      {showRoutines && <RoutinesPanel model={model} agents={agents} focusAgentId={routinesFocus}
        onClose={() => { setShowRoutines(false); setRoutinesFocus(null); }}
        onChanged={onRoutinesChanged}
        onOpenConv={id => { setActiveKind("chat"); setActiveId(id); setActiveAppId(null); loadConvs(); }}
        onOpenAgent={id => { setActiveKind("agent"); setActiveId(id); setActiveAppId(null); loadAgentFull(id); }} />}
      {showAppPicker && <AppPickerModal onCreate={createApp} creating={creatingApp}
        isAdmin={isAdmin}
        onClose={() => setShowAppPicker(false)} />}
      {confirm && <ConfirmModal {...confirm}
        onConfirm={() => { const go = confirm.onConfirm; setConfirm(null); go(); }}
        onClose={() => setConfirm(null)} />}
    </div>
  );
}

/* ---------- PWA install prompt ---------- */
const IosShareIcon = () => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ width: 15, height: 15, verticalAlign: "-3px" }}>
    <path d="M12 15V3M8 7l4-4 4 4" />
    <path d="M6 11H5a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-6a2 2 0 0 0-2-2h-1" />
  </svg>
);

/* Shared install manager — beforeinstallprompt is captured in the HTML head
   into window.__deferredInstall; both the banner and the sidebar button use
   this. */
const Install = (() => {
  const subs = new Set();
  const notify = () => subs.forEach(f => { try { f(); } catch {} });
  let showReq = 0;
  if (typeof window !== "undefined") window.addEventListener("bip-captured", notify);
  const ua = (typeof navigator !== "undefined" && navigator.userAgent) || "";
  const isIOS = /iphone|ipad|ipod/i.test(ua)
    || (typeof navigator !== "undefined" && navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
  const isInstalled = () => {
    try { return (window.matchMedia && window.matchMedia("(display-mode: standalone)").matches) || window.navigator.standalone === true; }
    catch { return false; }
  };
  return {
    isIOS,
    isInstalled,
    canPrompt: () => !!window.__deferredInstall,
    available: () => !isInstalled() && (isIOS || !!window.__deferredInstall),
    async prompt() {
      const e = window.__deferredInstall;
      if (!e) return false;
      e.prompt();
      try { await e.userChoice; } catch {}
      window.__deferredInstall = null; notify();
      return true;
    },
    requestShow() { showReq++; try { localStorage.removeItem("indie.pwa.dismissed"); } catch {} notify(); },
    showToken: () => showReq,
    subscribe(cb) { subs.add(cb); return () => subs.delete(cb); },
  };
})();

function useInstall() {
  const [, force] = useState(0);
  useEffect(() => Install.subscribe(() => force(n => n + 1)), []);
  return Install;
}

function InstallPrompt() {
  const inst = useInstall();
  const [dismissed, setDismissed] = useState(() => {
    try { return !!localStorage.getItem("indie.pwa.dismissed"); } catch { return false; }
  });
  const seenToken = useRef(inst.showToken());

  /* A sidebar "Install app" tap bumps the show token — re-show even if dismissed. */
  if (inst.showToken() !== seenToken.current) { seenToken.current = inst.showToken(); if (dismissed) setDismissed(false); }

  const dismiss = () => { setDismissed(true); try { localStorage.setItem("indie.pwa.dismissed", "1"); } catch {} };

  if (dismissed || !inst.available()) return null;
  return (
    <div className="pwa-banner" role="dialog" aria-label={t("pwa.install")}>
      <img className="pwa-ic" src="/icons/icon-192.png" alt="" />
      {inst.isIOS ? (
        <span className="pwa-txt">
          {tb("pwa.iosA", { brand: (L.BRAND.brandName || "indie.chat") })}<IosShareIcon />{tb("pwa.iosB")}
        </span>
      ) : (
        <span className="pwa-txt">{tb("pwa.add", { brand: (L.BRAND.brandName || "indie.chat") })}</span>
      )}
      {inst.canPrompt() && <button className="pwa-install" onClick={() => inst.prompt()}>{t("pwa.install")}</button>}
      <button className="pwa-x" onClick={dismiss} aria-label={t("common.dismiss")}>✕</button>
    </div>
  );
}

function Root() {
  /* Re-mount the whole tree when the language changes. Keying on the locale is
     blunt but correct: every t() call is evaluated during render, so anything
     short of a full re-render leaves stale strings in components that did not
     happen to update. It costs one re-mount on an action users take rarely. */
  const [lang, setLang] = useState(L.lang());
  useEffect(() => {
    const onLang = e => setLang(e.detail);
    window.addEventListener("indie:lang", onLang);
    return () => window.removeEventListener("indie:lang", onLang);
  }, []);
  return <React.Fragment key={lang}><App /><InstallPrompt /><WindowDrop /></React.Fragment>;
}

L.configureMarked();
L.seedAdmin();
ReactDOM.createRoot(document.getElementById("root")).render(<Root />);
