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

/* ---------- 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 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 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></>;
}

/* ========================================================
   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);

  /* 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 || "Account created — an administrator must approve it before you can sign in.");
        } else {
          onLogin(res);
        }
      }
    } catch (err) {
      setError(err.message || "Something went wrong");
    } finally {
      setLoading(false);
    }
  };

  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); }}>Sign in</button>
          <button className={"auth-tab " + (tab === "register" ? "on" : "")} onClick={() => { setTab("register"); setError(null); setNotice(null); }}>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">New accounts require administrator approval before first sign-in.</div>
          )}
          {tab === "register" && (
            <div className="field">
              <label>Name</label>
              <input name="name" autoComplete="name" value={name} onChange={e => setName(e.target.value)} placeholder="Jane Doe" required autoFocus />
            </div>
          )}
          <div className="field">
            <label>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>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" ? "Min. 6 characters" : "••••••••"} required />
          </div>
          <button type="submit" className="btn primary auth-submit" disabled={loading}>
            {loading ? "Please wait…" : (tab === "login" ? "Sign in →" : "Create account →")}
          </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 "Every minute";
  if (m === "0" && h === "*" && dom === "*" && mon === "*" && dow === "*") return "Every hour";
  if (/^\*\/\d+$/.test(m) && h === "*") return "Every " + m.slice(2) + " minutes";
  if (/^\d+$/.test(m) && /^\d+$/.test(h) && dom === "*" && mon === "*" && dow === "*") return "Every day at " + at(h, m);
  if (/^\d+$/.test(m) && /^\d+$/.test(h) && dom === "*" && mon === "*" && dow === "1-5") return "Weekdays at " + 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.
   ============================================================ */
const ROUTINE_CADENCES = [
  { key: "minute",   label: "Every minute (testing)" },
  { key: "30min",    label: "Every 30 minutes" },
  { key: "hourly",   label: "Every hour" },
  { key: "daily",    label: "Every day" },
  { key: "weekdays", label: "Weekdays" },
  { key: "weekly",   label: "Weekly" },
  { key: "custom",   label: "Custom (cron)" },
];

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,
  });
  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,
    });
    setEditId(r.id); setErr(null); setView("editor");
  };

  const save = async () => {
    setErr(null);
    if (!f.title.trim() || !f.prompt.trim()) { setErr("Give the routine a title and a prompt."); 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",
    };
    if (f.mode === "agent") {
      if (!f.agentId) { setErr("Pick the agent this routine runs on."); 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("Pick a valid provider."); return; }
      if (!f.model) { setErr("Pick the model this routine answers with."); 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>Title</label>
        <input value={f.title} onChange={e => set("title", e.target.value)}
               placeholder={f.mode === "agent" ? "Inbox duty" : "Morning crypto-reg digest"} /></div>
      <div className="field">
        <label>{f.mode === "agent" ? "Mission — what should it do each run?" : "Prompt"}</label>
        <textarea value={f.prompt} onChange={e => set("prompt", e.target.value)} rows={3}
                  placeholder={f.mode === "agent"
                    ? "Check the support inbox via MCP; answer routine tickets; flag anything urgent."
                    : "Search the mica docs for any updates this week and summarize them."} /></div>

      <div className="field"><label>Runs as</label>
        <div className="mode-seg">
          <button className={f.mode === "chat" ? "on" : ""} onClick={() => set("mode", "chat")}>
            <ClockIcon /> Chat
            <span>answers into its own conversation</span>
          </button>
          <button className={f.mode === "agent" ? "on" : ""} onClick={() => set("mode", "agent")}
                  disabled={agentList.length === 0}>
            <BotIcon /> Agent
            <span>{agentList.length === 0 ? "no agents yet" : "a full turn with workspace + tools"}</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">
            The agent supplies its own model, tools and workspace, so there's nothing else to configure.
            Each run lands in its transcript as a ⏰ turn, and while this routine is enabled the agent
            sits in the <b>Background</b> tab.
          </div>
        </>
      ) : (
        <>
          <div className="routine-grid">
            <div className="field"><label>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>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 /> 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>Cadence</label>
          <select value={f.kind} onChange={e => set("kind", e.target.value)}>
            {ROUTINE_CADENCES.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>Time</label>
            <input type="time" value={f.time} onChange={e => set("time", e.target.value)} /></div>
        )}
        {f.kind === "weekly" && (
          <div className="field"><label>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>Cron (min hour dom mon dow)</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="Send a push notification when this routine finishes a run">
          <BellIcon /> Notify
        </button>
        <span className="hint">Runs in your local time. Cron: <code>{buildCron()}</code></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 ? "Enabling…" : "Enable notifications on this device"}
          </button>
        </div>
      )}

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

  /* ---------------- list ---------------- */
  const list = (
    <>
      <div className="note">
        Scheduled work that runs on the server, whether or not you're connected. A routine runs either
        as a <b>chat</b> (answering into its own conversation) or on an <b>agent</b> (a full turn with
        its workspace and tools). Agent routines are the standing loops that keep an agent in the
        Background tab.
      </div>

      <div className="routine-filters">
        {[["all", "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}>+ New routine</button>
      </div>
      {focusAgentId && filter === "agent" && (
        <div className="hint" style={{ marginTop: -4 }}>
          Showing this agent's routines. Switch to <b>All</b> to see every routine you have.
        </div>
      )}

      {routines == null ? <div className="note">Loading…</div>
        : shown.length === 0 ? (
          <div className="note">
            {all.length === 0 ? "No routines yet — create one to have work happen on a schedule."
                              : "Nothing in this filter."}
          </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>Search</span></>}
                    {!isAgent(r) && r.mcp && <><span className="ri-dot">·</span><span>Tools</span></>}
                    {r.notify && <><span className="ri-dot">·</span><span>Notify</span></>}
                  </div>
                  <div className="ri-meta sub">
                    {r.enabled ? <>Next: {fmt(r.nextRun)}</> : <>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">Delete?</span>
                      <button className="act-btn danger" onClick={() => del(r)}>Yes, delete</button>
                      <button className="act-btn" onClick={() => setConfirmDel(null)}>Cancel</button>
                    </>
                  ) : (
                    <>
                      {isAgent(r)
                        ? <button className="act-btn" onClick={() => { onOpenAgent && onOpenAgent(r.agentId); onClose(); }}>Open agent</button>
                        : r.convId && <button className="act-btn" onClick={() => { onOpenConv(r.convId); onClose(); }}>Open</button>}
                      <button className="act-btn" onClick={() => runNow(r)}>Run now</button>
                      <button className="act-btn" onClick={() => toggle(r)}>{r.enabled ? "◼ Pause" : "▶ Resume"}</button>
                      <button className="act-btn" onClick={() => openEdit(r)}>Edit</button>
                      <button className="act-btn danger" onClick={() => setConfirmDel(r.id)}>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" ? "Scheduled work" : (editId ? "Edit" : "New")}</span>
            <h2>{view === "list" ? "Routines" : (editId ? "Edit routine" : "New routine")}</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); }}>Cancel</button>
            <button className="btn primary" onClick={save} disabled={busy}>
              {busy ? "Saving…" : (editId ? "Save changes" : "Create routine")}
            </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 FA_LABEL = { xlsx: "Excel spreadsheet", docx: "Word document", pdf: "PDF document" };
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) || "Generation failed"); 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 ? "Preparing file…" : (FA_LABEL[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" ? "Generating…" : state === "done" ? "Download again" : (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) || "Download failed"); 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" ? "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 ? "Hide details" : "Show full arguments and result") : 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">Arguments</div>
            <pre>{pretty(args)}</pre>
          </>)}
          <div className="ts-lbl mono">{res == null ? "Result — still running" : 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="Sent by a schedule, not typed">⏰ 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>
          You picked <b>{requestedLabel}</b>, but {provider || "the provider"} answered with <b>{modelLabel}</b>.
        </div>
      )}
      {m.searched && (
        <div className="search-pill"><span className="tt-dot" /> Searched the web</div>
      )}
      {m.redactions != null && (
        <div className="redaction-note">
          <span className="rn-ic"><ShieldIcon /></span>
          {m.redactions
            ? <span>Redacted before sending · <b>{L.redactionSummary(m.redactionTypes) || (m.redactions + (m.redactions > 1 ? " items" : " item"))}</b></span>
            : <span>Privacy filter on · nothing to redact</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)}>Cancel</button>
                  <button className="act-btn primary" onClick={saveEdit} disabled={!draft.trim()}>Save &amp; submit</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 ? "Thinking…" : "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>
            )}
          </>
        )}
      {!isUser && m.stopped && !streaming && <div className="stopped-note">⏹ Stopped — partial response kept</div>}
      {!isUser && m.queuedPos != null && streaming && <div className="stopped-note">⏳ Queued — waiting for a free agent slot (position {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}>Edit</button>}
          {!isUser && isLast && onRegenerate && m.content && <button className="act-btn" onClick={onRegenerate} disabled={busy}>Regenerate</button>}
        </div>
      )}
    </div>
  );
}

/* ---------- model selector ---------- */
function ModelSelect({ model, setModel, models }) {
  const [open, setOpen] = useState(false);
  const ref = useRef(null);
  const list = models || L.MODELS;
  const cur = list.find(m => m.id === model) || list[0];
  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" ref={ref}>
      <button className="model-btn" onClick={() => setOpen(!open)}>
        <span className="sq" />
        <span className="sel-label">{cur.label}</span>
        <span className="chev">▼</span>
      </button>
      {open && (
        <div className="model-menu">
          <div className="menu-cap">Model</div>
          {list.map(m => (
            <button key={m.id} className={"model-opt " + (m.id === model ? "sel" : "")}
                    onClick={() => { setModel(m.id); setOpen(false); }}>
              <span className="sq" />
              <span className="label">
                {m.label}
                <span className="desc">{m.desc}</span>
              </span>
              {m.id === model && <span className="check">✓</span>}
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

/* ---------- 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="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">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: "Explain how a VAT return works for a small German GmbH." },
  { k: "Draft",   t: "Draft a short, friendly reminder email about a missing invoice." },
  { k: "Code",    t: "Write a Python function that parses an SSE stream line by line." },
  { k: "Compare", t: "Compare a dense 27B model with a 35B mixture-of-experts model." },
];
function Empty({ onPick }) {
  return (
    <div className="empty">
      <div className="empty-mark" />
      <h1><BrandName /></h1>
      <p className="sub">A clean, private chat workspace running on indie models. Pick a model up top, ask anything below.</p>
      <div className="eyebrow">Try one of these</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); }
/* 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="Let the model use MCP tools">
        <ToolsIcon /> Tools
      </button>
      {hasServers && (
        <button className={"tt-caret-btn " + (on ? "on" : "")} title="Choose MCP servers"
                onClick={() => setOpen(o => !o)}>▾</button>
      )}
      {open && hasServers && (
        <div className="mcp-menu">
          <div className="mcp-menu-head">MCP servers</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));

  /* Images take the VISION path (read as a data URL, sent to the model as an
     image_url part). Every other file is converted to Markdown via MarkItDown
     and its text becomes context. */
  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;
      }
      if (image) {
        fileToImageDataUrl(f)
          .then(url => patchFile(id, { converting: false, dataUrl: url }))
          .catch(() => patchFile(id, { converting: false, error: "couldn't read image" }));
        return;
      }
      L.convertFile(f)
        .then(r => patchFile(id, { converting: false, text: r.markdown, chars: r.chars, truncated: r.truncated }))
        .catch(e => patchFile(id, { converting: false, error: (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" ? "Microphone permission denied" : "Couldn't start recording");
    }
  };

  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("Recording too short"); 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("Couldn't make out any speech");
      }
    } catch (e) {
      setVoiceErr((e && e.message) || "Transcription failed");
    } finally {
      setTranscribing(false);
    }
  };

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

  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), 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 ? "Reading image…" : "Converting to Markdown…") : (f.truncated ? f.name + " (truncated to fit context)" : f.name)}>
                {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">
                  {f.converting ? (f.isImage ? "reading…" : "converting…") : f.error ? "failed" : 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 className="attach-btn" title="Attach files" onClick={() => fileInput.current && fileInput.current.click()}><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 ? "Drop files to attach…" : ("Message " + (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 ? "Stop & transcribe" : transcribing ? "Transcribing…" : "Voice input"}>
              <MicIcon />
            </button>
          )}
          {busy
            ? <button className="stop" onClick={onStop} title="Stop"><span className="sq" /></button>
            : <button className="send" onClick={submit} disabled={converting || recording || transcribing || (!text.trim() && !files.length)} title={converting ? "Converting attachments…" : "Send"}><SendIcon /></button>}
        </div>

        <div className="composer-foot">
          <div className="foot-left">
            {searchAvailable && (
              <button className={"tool-toggle " + (search ? "on" : "")} onClick={() => setSearch(s => !s)} title="Web search">
                <SearchIcon /> Search
              </button>
            )}
            {privacyAvailable && (
              <button className={"tool-toggle " + (priv ? "on" : "")} onClick={() => setPriv(p => !p)}
                      title="Strip PII before sending to the model (OpenAI Privacy Filter)">
                <ShieldIcon /> 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="Let the model create downloadable Excel/Word/PDF files">
                <FileGenIcon /> Make file
              </button>
            )}
            {recording
              ? <span className="voice-status rec"><span className="rec-dot" /> Recording… tap mic to stop</span>
              : transcribing
              ? <span className="voice-status"><span className="rec-dot busy" /> Transcribing…</span>
              : voiceErr
              ? <span className="voice-status err">{voiceErr}</span>
              : <span className="hint"><b>Enter</b> to send · <b>Shift+Enter</b> for newline</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); } }}
    />
  );
}

/* ---------- 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 || "New conversation", 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: "⚠ interrupted — open to resume" + 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}>New agent <span className="plus"><BotIcon /></span></button>
        <button className="new-caret" title="New…" onClick={() => setMenuOpen(o => !o)}>▾</button>
        {menuOpen && (
          <>
            <div className="new-scrim" onClick={() => setMenuOpen(false)} />
            <div className="new-menu">
              <button onClick={() => { setMenuOpen(false); onNewAgent(); }}><BotIcon /> New agent <span className="beta">beta</span></button>
              <button onClick={() => { setMenuOpen(false); onNew(); }}><PlusIcon /> New chat session</button>
            </div>
          </>
        )}
      </div>
      <div className="conv-head mono apps-head">
        Apps
        <button className="apps-add" title="New app" 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="Global app — shared with every user">G</span>}
              </div>
              {(a.scope !== "global" || isAdmin) && (
                <button className="conv-del" title={a.scope === "global" ? "Delete global app (admin)" : "Delete app"}
                        onClick={ev => { ev.stopPropagation(); onDeleteApp(a.id); }}>✕</button>
              )}
            </div>
          ))}
        </div>
      )}
      {(apps || []).length > 0 && (
        <Splitter axis="h" label="Apps / agents split"
                  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")}>Foreground</button>
        <button className={"side-tab " + (sideTab === "bg" ? "on" : "")} onClick={() => onSideTab("bg")}>
          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)" }}>
              Nothing yet
            </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="Shared agent — available to everyone">G</span>}
              </div>
              <div className="conv-meta">{L.relTime(e.updated)}</div>
              {isLibrarian(e)
                ? <span className="conv-locked" title="The Wiki Librarian maintains the shared wiki and cannot be deleted">✎</span>
                : <button className="conv-del" title="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 }}>
              Agents appear here automatically when they get a standing <b>routine</b> (a mission run on a cadence — add one under <b>Routines</b>, or via the ⏰ button in the agent toolbar) or a running <b>service</b>. Pause the routines, stop the services, and they return to 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="The Wiki Librarian maintains the shared wiki and cannot be deleted">✎</span>
                  : <button className="conv-del" title="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="Routines — scheduled work, in chat or on an agent" onClick={onRoutines}>
          <ClockIcon /> Routines
        </button>
        {(isAdmin || inst.available()) && (<>
          {inst.available() && (
            <button className="install-link" title="Install indie.chat as an app"
                    onClick={() => { if (inst.canPrompt()) inst.prompt(); else inst.requestShow(); }}>
              <DownloadIcon /> Install app
            </button>
          )}
          {isAdmin && <a className="admin-link" href="admin.html" title="Admin"><ShieldIcon /> Admin</a>}
        </>)}
      </div>

      {user && (
        <div className="user-foot">
          <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>
          <button className="logout-btn" onClick={onLogout} title="Sign out">⏻</button>
        </div>
      )}
      <Splitter axis="v" className="side-edge" label="Sidebar width"
                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")
    return <iframe className="canvas-frame" sandbox="allow-scripts allow-popups" srcDoc={payload.content || ""} title="HTML preview" />;
  if (kind === "url")
    return <iframe className="canvas-frame" sandbox="allow-scripts allow-popups allow-forms allow-same-origin" src={payload.content || ""} title="Website preview" />;
  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">Loading PDF…</div>;
    return <iframe className="canvas-frame" src={ld.url} title="PDF preview" />;
  }
  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} />;
}

/* 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, busy, reconnecting, engineReady, onSend, onStop, orphan, onDismissOrphan, onOpenRoutines }) {
  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 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 || [];
  const modelLabel = (L.findModel(agent.model) || {}).label || agent.model || "—";
  const provLabel  = 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("Couldn't stop the service: " + ((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("Couldn't read logs: " + ((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("Download failed: " + 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. */
      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 }))
        .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" ? "Microphone permission denied" : "Couldn't start recording");
    }
  };
  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("Recording too short"); 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("Couldn't make out any speech");
    } catch (e) { setVoiceErr((e && e.message) || "Transcription failed"); }
    finally { setTranscribing(false); }
  };
  const toggleMic = () => { if (transcribing) return; recording ? stopRecording() : startRecording(); };

  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);
    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"><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="This agent runs a standing routine and/or a service — open Routines (clock) to see and control it.">◉ loop</span>}
          <button className={"icon-btn" + (agent.background ? " on" : "")}
                  title="Routines — scheduled work, filtered to this agent (view, edit, start/stop)"
                  onClick={() => onOpenRoutines && onOpenRoutines(agent.id)}><ClockIcon /></button>
          {canvases.length > 0 && <button className={"icon-btn" + (canvasOpen ? " on" : "")} title="Canvas" onClick={() => setCanvasOpen(o => !o)}><CanvasIcon /></button>}
          <button className={"icon-btn" + (showFiles ? " on" : "")} title="Workspace files" onClick={toggleFiles}><FilesIcon /></button>
          <a className="icon-btn" title="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("Continue where you left off — the previous run was interrupted.", [], false); onDismissOrphan && onDismissOrphan(orphan.id); }}>Resume</button>
            <button className="x" onClick={() => onDismissOrphan && onDismissOrphan(orphan.id)}>✕</button>
          </div>
        </div>
      )}

      <div className="scroll">
        {msgs.length === 0
          ? (
            <div className="agent-empty">
              <div className="agent-empty-ic"><BotIcon /></div>
              <h2>{agent.name}</h2>
              <p>A persistent coding agent with its own workspace: file editing, shell, and skills, powered by <b>pi</b>. Ask it to build, edit, or run things.</p>
              <p className="agent-empty-model">{modelLabel} · 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>

      {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 }}>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)}>Logs</button>
                    <button className="agent-files-refresh" style={{ color: "var(--maroon)" }} onClick={() => stopSvc(s.name)}>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">No files yet. Ask the agent to create one.</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="Download">
                    {dl === f.path ? "…" : <DownloadIcon />}
                  </button>
                </div>
              ))}
            </div>
          )}
        </div>
      )}

      {reconnecting && (
        <div style={{ padding: "0 24px" }}>
          <div className="reconnect-banner"><span className="rec-dot busy" /> Reconnecting… the agent keeps running on the server.</div>
        </div>
      )}

      {engineReady === false ? (
        <div className="composer-wrap">
          <div className="agent-coming">
            ⚙ The agent engine isn't available on this server yet (Docker + the <code>indie-agent</code> image are required).
            The agent is saved; turns will run once the engine is enabled.
          </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 ? "Uploading to workspace…" : (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 className="attach-btn" title="Attach files to the workspace" onClick={() => fileInput.current && fileInput.current.click()}><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 ? "Drop files into the workspace…" : "Tell the agent what to build or change…"}
                        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 ? "Stop & transcribe" : transcribing ? "Transcribing…" : "Voice input"}>
                  <MicIcon />
                </button>
              )}
              {busy
                ? <button className="stop" onClick={onStop} title="Stop"><span className="sq" /></button>
                : <button className="send" onClick={submit} disabled={converting || recording || transcribing || (!text.trim() && !attach.length)} title={converting ? "Uploading attachments…" : "Send"}><SendIcon /></button>}
            </div>
          </div>
          <div className="composer-foot">
            <div className="foot-left">
              {privacyAvailable && (
                <button className={"tool-toggle " + (priv ? "on" : "")} onClick={() => setPriv(p => !p)}
                        title="Strip PII (emails, phones, secrets, …) from your prompt before it reaches the agent's model">
                  <ShieldIcon /> Private <span className="tt-sub">(OPF)</span>
                </button>
              )}
              {recording
                ? <span className="voice-status rec"><span className="rec-dot" /> Recording… tap mic to stop</span>
                : transcribing
                ? <span className="voice-status"><span className="rec-dot busy" /> Transcribing…</span>
                : voiceErr
                ? <span className="voice-status err">{voiceErr}</span>
                : <span className="hint">Runs <b>pi</b> in this agent's workspace · <b>Enter</b> to send</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">Open ↗</a>}
            {(canvas.kind === "pdf" || canvas.kind === "xlsx") && <button className="acv-open" onClick={() => download(canvas.path)}>Download</button>}
            <button className="acv-close" title="Close canvas" 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;
}

function AppWorkspace({ app, busy, appStateTick, appHtmlTick, onAsk, onMenu, locked, onLock,
                        agents, dockedAgent, onLinkAgent, onNewAgent }) {
  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) || "Couldn't load the app"); });
    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, "The app is not rendered right now").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, "Couldn't reach the app frame").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));
        else if (d.method === "ask") {
          if (onAsk) { onAsk(String((d.params && d.params.text) || "").slice(0, 4000)); reply({ ok: true }); }
          else reply(null, "No agent is linked to this app yet — pick one in the chat panel.");
        }
        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">
          {/* 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
                      ? "Docked agent: " + dockedAgent.name + " — click to switch or disconnect"
                      : "No agent is docked to this app — click to pick one"}>
              <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">Docked agent</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 /> New agent for this app
                </button>
                {app.agentId && (
                  <button className="aam-off" onClick={() => { setAgentMenu(false); onLinkAgent(null); }}>
                    ⊘ Disconnect agent
                  </button>
                )}
              </div>
            </>)}
          </div>
          <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>
          <button className="icon-btn" title="Reload app" onClick={() => setFrameKey(k => k + 1)}><RefreshIcon /></button>
        </div>
      </div>
      {err && <div className="appws-note">{err}</div>}
      {!full && !err && <div className="appws-note mono">Loading…</div>}
      {/* allow-downloads lets an app hand the user a file it built from its own
          data (a CSV export). Downloads still need a user gesture and the frame
          stays origin-less, so this grants no new reach. */}
      {full && (
        <iframe key={frameKey + ":" + appHtmlTick} ref={frameRef} className="appws-frame" title={app.name}
                sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads"
                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>Pick this app's agent</h3>
      <p>The agent chats on the right and can read, update, and rebuild the app while you use it.</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 /> New agent for this app</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">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}>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 APP_TEMPLATE_FALLBACK = [
  { key: "finance", icon: "📈", name: "Markets", description: "Live watchlist — quotes, sparklines, ranges (Yahoo Finance connector)." },
  { key: "notes",   icon: "🗒️", name: "Notes", description: "Persistent notes the agent can read, search, and edit with you." },
  { key: "blank",   icon: "▦",  name: "New app", description: "Start empty and tell your agent what to build." },
];
function AppPickerModal({ onCreate, onClose, creating, isAdmin }) {
  const [global, setGlobal] = useState(false);
  const templates = L.appTemplateList() || APP_TEMPLATE_FALLBACK;
  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">Apps</span><h2>New app</h2></div>
          <button className="modal-x" onClick={onClose}>✕</button>
        </div>
        <div className="modal-body">
          {templates.map(t => (
            <button key={t.key} className="app-tpl" disabled={!!creating} onClick={() => onCreate(t.key, global)}>
              <span className="app-tpl-ic">{t.icon}</span>
              <span className="app-tpl-tx">
                <b>{t.name}</b>
                <span>{t.description || t.desc}</span>
              </span>
              {creating === t.key && <span className="app-tpl-busy">creating…</span>}
            </button>
          ))}
          {isAdmin && (
            <label className="app-global-opt">
              <input type="checkbox" checked={global} onChange={e => setGlobal(e.target.checked)} />
              Add for everyone (global) — one shared app + shared data for all users; only admins can edit or delete it
            </label>
          )}
        </div>
      </div>
    </div>
  );
}

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

  /* --- auth state --- */
  const [user,         setUser]         = useState(L.getCachedUser()); // show cached immediately
  const [authChecked,  setAuthChecked]  = 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 [appHtmlTick,  setAppHtmlTick]  = useState(0);  /* agent rewrote the open app */
  /* 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 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;
  const providerModels = L.modelsFor(curProvider);
  const modelLabel    = (L.findModel(model) || providerModels[0]).label;

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

  /* Keep the selected model valid for the chosen provider. */
  useEffect(() => {
    if (!providerModels.some(m => m.id === model)) setModel(providerModels[0].id);
  }, [curProvider]);

  /* 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. If the selected model is not in the deployment's catalog
     (a fresh browser, or a catalog that changed under us), fall back to its
     default rather than silently asking for a model that does not exist. */
  useEffect(() => {
    if (!user) return;
    L.loadConfig().then(() => {
      setBrandV(v => v + 1);
      setModel(m => (L.findModel(m) ? m : L.DEFAULT_MODEL));
    });
  }, [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 --- */
  useEffect(() => {
    L.Auth.me().then(u => {
      setUser(u);
      setAuthChecked(true);
      if (u) { loadConvs(); loadAgents(); loadApps(); }
    }).catch(() => {
      setUser(null);
      setAuthChecked(true);
    });
  }, []);

  /* 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("Failed to load conversations:", 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("Failed to load agents:", e); }
    finally { setAgentsLoaded(true); }
  };
  const loadApps = async () => {
    try { setApps(await L.AppsAPI.list()); }
    catch (e) { console.error("Failed to load apps:", 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);
        const busyAny = a && a.runs && a.runs.some(r => r.status === "running" || r.status === "queued");
        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("Failed to load agent:", e);
    }
  };

  /* --- autoscroll while streaming --- */
  const scrollToBottom = useCallback(() => {
    const el = scrollRef.current; if (!el) return;
    el.scrollTop = el.scrollHeight;
  }, []);
  useEffect(() => {
    const el = scrollRef.current; if (!el) return;
    const onScroll = () => {
      stickRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
    };
    el.addEventListener("scroll", onScroll);
    return () => el.removeEventListener("scroll", onScroll);
  }, []);

  /* --- 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);
  };

  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) || "Couldn't create the app"); }
    finally { setCreatingApp(null); }
  };
  const deleteApp = async id => {
    setApps(as => as.filter(a => a.id !== id));
    if (id === activeAppId) setActiveAppId(null);
    try { await L.AppsAPI.remove(id); }
    catch (e) { setError((e && e.message) || "Couldn't delete the app"); loadApps(); }
  };
  const askDeleteApp = id => {
    const a = (apps || []).find(x => x.id === id);
    if (!a) return;
    setConfirm({
      title: "Delete " + (a.name || "this app") + "?",
      confirmLabel: "Delete app",
      body: (<>
        <p>The app's code and its saved state are deleted. This cannot be undone. The agent docked to it is not affected.</p>
        {a.scope === "global" && <p className="warn-line">This is a global app: it disappears for <b>every user</b>, along with the state they share.</p>}
      </>),
      onConfirm: () => deleteApp(id),
    });
  };
  /* User-only code lock: locked apps can't be rewritten/deleted by the agent. */
  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) || "Couldn't change the lock"); }
  };
  /* 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) || "Couldn't link the agent"); }
  };
  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) || "Couldn't create an agent"); }
  };
  /* 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);
    else if (e.action === "updated") setAppHtmlTick(t => t + 1);
    else if (e.action === "deleted") setActiveAppId(cur => cur === e.id ? null : cur);
  };

  /* 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) || "Couldn't delete the agent"); 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: "Delete " + (a.name || "this agent") + "?",
      confirmLabel: "Delete agent",
      body: (<>
        <p>Its conversation and its persistent workspace — every file it has written — are deleted. This cannot be undone.</p>
        {(svc > 0 || loops > 0) && (
          <p className="warn-line">
            {svc > 0 && <>{svc} running service{svc > 1 ? "s" : ""} will be stopped and removed. </>}
            {loops > 0 && <>{loops} standing loop{loops > 1 ? "s" : ""} will stop firing.</>}
          </p>
        )}
        {a.scope === "global" && <p className="warn-line">This is a shared agent: it disappears for <b>every user</b>.</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); }
  };

  /* 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) => {
    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 }); runId = r.runId; }
    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)),
      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 || [];
    const imageFiles = fileList.filter(f => f.isImage && f.dataUrl);   /* vision path */
    const textFiles  = fileList.filter(f => f.text && !f.isImage);     /* MarkItDown path */
    const attachMeta = textFiles.map(f => ({ name: f.name, size: f.size, type: f.type }));
    const images     = imageFiles.map(f => ({ name: f.name, dataUrl: f.dataUrl }));

    /* 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)); },
        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 ---- */

  /* 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" }}>
          Loading…
        </span>
      </div>
    );
  }

  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">
        {activeApp ? (
          <div className="appws" ref={wsRef}>
            <AppWorkspace app={activeApp} busy={busy}
                          appStateTick={appStateTick} appHtmlTick={appHtmlTick}
                          locked={!!activeApp.locked} onLock={toggleAppLock}
                          onMenu={() => setNavOpen(true)}
                          agents={agents} dockedAgent={appAgent}
                          onLinkAgent={linkAppAgent} onNewAgent={newAgentForApp}
                          onAsk={appAgent ? (text => agentSend(appAgent, text, [], false)) : null} />
            <Splitter axis="v" label="App / agent split"
                      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 agent={appAgent} onMenu={() => setNavOpen(true)} onRename={renameAgent}
                           busy={busy} reconnecting={reconnecting} engineReady={engineReady}
                           orphan={orphanFor(appAgent.id)} onDismissOrphan={dismissOrphan}
                           onOpenRoutines={openRoutines}
                           onSend={(text, files, priv) => agentSend(appAgent, text, files, priv)} onStop={stop} />
              ) : (
                <AppAgentPicker agents={agents} onPick={linkAppAgent} onNew={newAgentForApp} />
              )}
            </div>
          </div>
        ) : activeKind === "agent" && activeAgent ? (
          <AgentView agent={activeAgent} onMenu={() => setNavOpen(true)} onRename={renameAgent}
                     busy={busy} reconnecting={reconnecting} engineReady={engineReady}
                     orphan={orphanFor(activeAgent.id)} onDismissOrphan={dismissOrphan}
                     onOpenRoutines={openRoutines}
                     onSend={(text, files, priv) => agentSend(activeAgent, text, files, priv)} 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">Chat</span><span className="tt-name" title={active.title}>{active.title}</span></>
                : <><span className="tt-kind">New</span><span className="tt-name">Start a conversation</span></>}
            </div>
          </div>
          <div className="tools">
            <ProviderSelect
              provider={provider} live={endpoint}
              setProvider={name => { L.setProvider(name); setProviderState(name); }} />
            <ModelSelect model={model} setModel={setModel} models={providerModels} />
            <a className="icon-btn" title="Settings" href="/settings.html"><GearIcon /></a>
          </div>
        </div>

        <div className="scroll" ref={scrollRef}>
          {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>

        {reconnecting && (
          <div style={{ padding: "0 24px" }}>
            <div className="reconnect-banner">
              <span className="rec-dot busy" /> Reconnecting… the answer keeps generating on the server.
            </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" }}>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={!!user && ["owner", "admin"].includes(user.role)}
        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="Install indie.chat">
      <img className="pwa-ic" src="/icons/icon-192.png" alt="" />
      {inst.isIOS ? (
        <span className="pwa-txt">
          Install <b>indie.chat</b>: tap <IosShareIcon /> <b>Share</b>, then <b>Add to Home Screen</b>.
        </span>
      ) : (
        <span className="pwa-txt">Add <b>indie.chat</b> to your home screen.</span>
      )}
      {inst.canPrompt() && <button className="pwa-install" onClick={() => inst.prompt()}>Install</button>}
      <button className="pwa-x" onClick={dismiss} aria-label="Dismiss">✕</button>
    </div>
  );
}

function Root() {
  return <><App /><InstallPrompt /></>;
}

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