// Copyright 2026 CiCy AI
// SPDX-License-Identifier: Apache-2.0

import { useEffect, useState, useCallback, useMemo, useRef } from "react";
import { createPortal } from "react-dom";
import "./App.css";
import { TERMS_VERSION, termsForDisplay } from "./termsText";
import { mdToHtml } from "./mdLite";
import { buildCustomTeamEditPatch } from "./custom-team-edit";

// i18n bridge exposed by homepage-preload (window.cicyI18n.t, locale from
// app.getLocale()). Returns the localized string, or `fallback` when the key
// is missing or we're running outside Electron.
const tr = (key, fallback, params) => {
  // 支持 {{name}} 插值:第三参是 { name: ... }。i18n 没命中时也对 fallback 插值。
  const interp = (s) => (params && typeof s === "string"
    ? s.replace(/\{\{\s*(\w+)\s*\}\}/g, (m, k) => (params[k] != null ? String(params[k]) : m))
    : s);
  try {
    const v = window.cicyI18n?.t?.(key, params);
    return interp(v && v !== key ? v : fallback);
  } catch { return interp(fallback); }
};

const TOKEN_KEY = "cicy_token";
const ACCESS_TOKEN_KEY = "cicy_access_token";
const USER_ID_KEY = "cicy_user_id";
// 未登录直接进入(离线/访客):只用本地能力(自定义团队、本地 Docker 团队),云端功能等登录。
const GUEST_KEY = "cicy_guest";
const CLOUD_BASE = "https://cicy-ai.com";

// cicy-ai 云端页面(我的钱包/团队帐单/新加团队)统一开在 **profile 1** 的
// app 内标签里(profile 1 走 proxy),不再用系统外部浏览器。URL 保持 CLEAN —— 不带任何
// token(钱包/团队账单 URL 不要带 token,连一次性票据 ?t 也不要)。dash 用
// profile 1 自己的 cicy-ai.com 会话鉴权;没登录会跳 /login 再回来。`query` 是 /dash 之后
// 的部分,如 "/wallet" / "?team=14"。
const CLOUD_PROFILE = 1; // cicy-ai 云端页面用的 profile(走 proxy)
async function openCloudPage(query) {
  const url = `${CLOUD_BASE}/dash${query}`;
  try {
    if (window.cicy?.tabs?.openIn) { await window.cicy.tabs.openIn(CLOUD_PROFILE, url, "cicy-ai"); return; }
    window.cicy?.shell?.openExternal?.(url); // 兜底:旧 preload 没有 openIn 时仍走系统浏览器
  } catch { try { window.cicy?.shell?.openExternal?.(url); } catch {} }
}

// ── Toast: lightweight global notifications (bottom-right). Pub/sub store so
// any component can push without prop-drilling — one <ToastHost/> at the shell
// root renders them. Used for 更新/启动/重启 progress + result so feedback floats
// over the UI instead of being buried inside a card. show() upserts by id, so a
// long-running op keeps ONE toast and just streams message/progress into it.
const toastListeners = new Set();
let toastSeq = 0;
let toastItems = [];
const toastTimers = new Map();
function emitToasts() { toastListeners.forEach((l) => l(toastItems)); }
const toast = {
  show(opts = {}) {
    const id = opts.id || `t${++toastSeq}`;
    const prev = toastItems.find((t) => t.id === id);
    const next = { id, status: "running", ...prev, ...opts };
    toastItems = prev ? toastItems.map((t) => (t.id === id ? next : t)) : [...toastItems, next];
    emitToasts();
    const old = toastTimers.get(id);
    if (old) { clearTimeout(old); toastTimers.delete(id); }
    if (opts.ttl) toastTimers.set(id, setTimeout(() => toast.dismiss(id), opts.ttl));
    return id;
  },
  dismiss(id) {
    toastItems = toastItems.filter((t) => t.id !== id);
    const tm = toastTimers.get(id);
    if (tm) { clearTimeout(tm); toastTimers.delete(id); }
    emitToasts();
  },
};
function ToastHost() {
  const [items, setItems] = useState(toastItems);
  useEffect(() => { toastListeners.add(setItems); return () => { toastListeners.delete(setItems); }; }, []);
  if (!items.length) return null;
  return (
    <div className="toast-host" data-id="ToastHost">
      {items.map((t) => (
        <div key={t.id} className="toast" data-id={`Toast-${t.id}`} data-status={t.status || "running"}>
          <button type="button" className="toast__x" data-id="Toast-dismiss" onClick={() => toast.dismiss(t.id)} aria-label="dismiss">×</button>
          <span className="toast__msg">
            {t.message}{Number.isFinite(t.progress) ? ` ${t.progress}%` : ""}
          </span>
          {Number.isFinite(t.progress) && (
            <span className="toast__bar"><span style={{ width: `${Math.min(100, t.progress)}%` }} /></span>
          )}
        </div>
      ))}
    </div>
  );
}

// ── Update drawer: a bottom sheet that streams the live update log (下载→切换→
// 完成), surfaces the exact step it's on, and offers 重试 when it fails — so a
// stuck/slow update is legible and recoverable instead of a frozen "更新中…".
// The sidecar update op emits {phase,status,message} on 'sidecar:op-progress';
// runOp tees those into this store. Single global instance, mounted at shell root.
const drawerListeners = new Set();
let drawerLogSeq = 0;
let drawerState = null; // null = closed
function emitDrawer() { drawerListeners.forEach((l) => l(drawerState)); }
function clockHHMMSS() { const d = new Date(); return d.toTimeString().slice(0, 8); }
const updateDrawer = {
  open({ teamId, fromVer, toVer, onRetry, title, kind } = {}) {
    drawerState = {
      teamId, title: title || null, fromVer: fromVer || null, toVer: toVer || null,
      kind: kind || "sidecar",   // "sidecar"(cicy-code 更新,有 stepper) | "app"(应用自更新,显进度条)
      status: "running",   // running | ready | done | error
      phase: "download",   // download | swap | done
      progress: null,      // app 自更新下载进度 {percent,transferred,total,bytesPerSec,etaSec}
      onInstall: null,     // app 自更新:下载完后「安装」回调
      logs: [],
      onRetry: onRetry || null,
      lastAt: Date.now(),
    };
    emitDrawer();
  },
  // app 自更新:更新下载进度条(不刷 log)。
  setProgress(p) {
    if (!drawerState) return;
    drawerState = { ...drawerState, status: "running", progress: p || drawerState.progress, lastAt: Date.now() };
    emitDrawer();
  },
  // app 自更新:下载完成,待用户点「安装」。
  ready({ onInstall, message } = {}) {
    if (!drawerState) return;
    const line = { id: ++drawerLogSeq, t: clockHHMMSS(), phase: "done", status: "done", message: message || tr("updateBanner.readyDrawer", "下载完成,点「安装」重启更新") };
    drawerState = { ...drawerState, status: "ready", phase: "done", progress: { ...(drawerState.progress || {}), percent: 100 }, onInstall: onInstall || null, minimized: false, logs: [...drawerState.logs, line], lastAt: Date.now() };
    emitDrawer();
  },
  push(ev = {}) {
    if (!drawerState) return;
    const line = { id: ++drawerLogSeq, t: clockHHMMSS(), phase: ev.phase || drawerState.phase, status: ev.status || "running", message: ev.message || "" };
    drawerState = {
      ...drawerState,
      phase: ev.phase || drawerState.phase,
      toVer: ev.toVer || drawerState.toVer,
      logs: [...drawerState.logs, line],
      lastAt: Date.now(),
    };
    emitDrawer();
  },
  minimize() { if (drawerState) { drawerState = { ...drawerState, minimized: true }; emitDrawer(); } },
  restore() { if (drawerState) { drawerState = { ...drawerState, minimized: false }; emitDrawer(); } },
  finish({ ok, message } = {}) {
    if (!drawerState) return;
    const status = ok ? "done" : "error";
    const line = { id: ++drawerLogSeq, t: clockHHMMSS(), phase: "done", status, message: message || (ok ? tr("updateDrawer.done", "更新完成") : tr("updateDrawer.failed", "更新失败")) };
    drawerState = { ...drawerState, status, phase: "done", minimized: false, logs: [...drawerState.logs, line], lastAt: Date.now() };
    emitDrawer();
  },
  close() { drawerState = null; emitDrawer(); },
};
const DRAWER_PHASE_KEYS = ["download", "swap", "done"];
// 渲染期取 label(模块加载时 i18n 桥可能还没就绪 → 必须运行时算,否则非中文用户看到中文)。
const drawerPhaseLabel = (k) => ({
  download: tr("updateDrawer.phaseDownload", "下载"),
  swap: tr("updateDrawer.phaseSwap", "切换"),
  done: tr("updateDrawer.phaseDone", "完成"),
}[k] || k);
function UpdateDrawerHost() {
  const [st, setSt] = useState(drawerState);
  useEffect(() => { drawerListeners.add(setSt); return () => { drawerListeners.delete(setSt); }; }, []);
  const logRef = useRef(null);
  useEffect(() => { const el = logRef.current; if (el) el.scrollTop = el.scrollHeight; }, [st?.logs?.length]);
  // Stuck detector: running but no new log line for 25s → the verify/probe wait
  // is taking long; nudge the user (they can keep it in the background).
  const [stuck, setStuck] = useState(false);
  useEffect(() => {
    if (!st || st.status !== "running") { setStuck(false); return; }
    const id = setInterval(() => setStuck(Date.now() - (st.lastAt || 0) > 25000), 3000);
    return () => clearInterval(id);
  }, [st?.lastAt, st?.status]);

  if (!st) return null;
  const running = st.status === "running";
  const phaseIdx = DRAWER_PHASE_KEYS.findIndex((k) => k === st.phase);
  if (st.minimized) {
    return (
      <button type="button" className={`drawer-min drawer-min--${st.status}`} data-id="UpdateDrawer-restore" onClick={() => updateDrawer.restore()}>
        <span className="drawer-min__spark">{running ? <Spinner /> : st.status === "done" ? "✓" : st.status === "reboot" ? "⟳" : "!"}</span>
        <span className="drawer-min__label">{st.title || tr("updateDrawer.title", "更新 cicy-code")}{st.toVer ? ` · v${st.toVer}` : ""}</span>
      </button>
    );
  }
  return (
    <div className="drawer-scrim" data-id="UpdateDrawer-scrim" onClick={() => running ? updateDrawer.minimize() : updateDrawer.close()}>
      <div className="drawer" data-id="UpdateDrawer" data-status={st.status} onClick={(e) => e.stopPropagation()}>
        <div className="drawer__head">
          <div className="drawer__title">
            <span className={`drawer__spark drawer__spark--${st.status}`}>
              {running ? <Spinner /> : st.status === "done" ? "✓" : st.status === "reboot" ? "⟳" : "!"}
            </span>
            <div>
              <div className="drawer__h">{st.title || tr("updateDrawer.title", "更新 cicy-code")}</div>
              <div className="drawer__sub">{st.fromVer ? `v${st.fromVer}` : tr("updateDrawer.current", "当前")} → {st.toVer ? `v${st.toVer}` : tr("updateDrawer.latest", "最新版")}</div>
            </div>
          </div>
          <div className="drawer__headbtns">
            <button type="button" className="drawer__x" data-id="UpdateDrawer-min" title={tr("updateDrawer.minimize", "最小化")} onClick={() => updateDrawer.minimize()} aria-label="minimize">‒</button>
          </div>
        </div>

        {st.kind !== "app" && (
        <div className="drawer__steps" data-id="UpdateDrawer-steps">
          {DRAWER_PHASE_KEYS.map((k, i) => {
            const label = drawerPhaseLabel(k);
            const done = st.status === "done" || i < phaseIdx;
            const active = i === phaseIdx && running;
            const err = st.status === "error" && i === phaseIdx;
            return (
              <div key={k} className={`drawer__step${active ? " is-active" : ""}${done ? " is-done" : ""}${err ? " is-error" : ""}`}>
                <span className="drawer__step-dot">{done ? "✓" : err ? "!" : i + 1}</span>
                <span className="drawer__step-label">{label}</span>
                {i < DRAWER_PHASE_KEYS.length - 1 && <span className="drawer__step-bar" />}
              </div>
            );
          })}
        </div>
        )}

        {/* app 自更新:醒目下载进度条(像 Docker 安装那样)*/}
        {st.progress && (() => {
          const pr = st.progress, mb = (b) => (b ? (b / 1048576).toFixed(1) : "0");
          return (
            <div className="drawer__dl" data-id="UpdateDrawer-dl">
              <div className="drawer__dl-head">
                <span className="drawer__dl-pct">{pr.percent || 0}%</span>
                <span className="drawer__dl-stats">
                  {pr.total ? `${mb(pr.transferred)} / ${mb(pr.total)} MB` : `${mb(pr.transferred)} MB`}
                  {pr.bytesPerSec ? ` · ${mb(pr.bytesPerSec)} MB/s` : ""}
                  {pr.etaSec ? ` · ${tr("updateBanner.eta", "剩 {{s}}s", { s: pr.etaSec })}` : ""}
                </span>
              </div>
              <div className="drawer__dl-track"><span className="drawer__dl-fill" style={{ width: `${pr.percent || 0}%` }} /></div>
            </div>
          );
        })()}

        <div className="drawer__log" data-id="UpdateDrawer-log" ref={logRef}>
          {st.logs.length === 0
            ? <div className="drawer__log-empty">{tr("updateDrawer.preparing", "准备中…")}</div>
            : st.logs.map((l) => (
              <div key={l.id} className="drawer__line" data-status={l.status}>
                <span className="drawer__t">{l.t}</span>
                <span className={`drawer__badge drawer__badge--${l.phase}`}>{drawerPhaseLabel(l.phase)}</span>
                <span className="drawer__linemsg">{l.message}</span>
              </div>
            ))}
        </div>

        {stuck && running && (
          <div className="drawer__hint" data-id="UpdateDrawer-stuck">
            {tr("updateDrawer.stuckHint", "正在等待新版本就绪，耗时比平常久。可以放到后台继续，完成或失败都会提示。")}
          </div>
        )}

        <div className="drawer__foot">
          {running ? (
            <>
              <span className="drawer__foot-status">{st.kind === "app" ? tr("updateBanner.downloading", "正在下载更新") + "…" : tr("updateDrawer.inProgress", "更新进行中…")}</span>
            </>
          ) : st.status === "ready" ? (
            <>
              <span className="drawer__foot-status is-done">{tr("updateBanner.readyShort", "下载完成")}</span>
              <button type="button" className="drawer__btn is-accent" data-id="UpdateDrawer-install" onClick={() => st.onInstall && st.onInstall()}>{tr("updateBanner.installBtn", "立即安装")}</button>
              <button type="button" className="drawer__btn" data-id="UpdateDrawer-later" onClick={() => updateDrawer.close()}>{tr("updateBanner.later", "稍后")}</button>
            </>
          ) : st.status === "error" ? (
            <>
              <span className="drawer__foot-status is-error">{tr("updateDrawer.failed", "更新失败")}</span>
              {st.onRetry && <button type="button" className="drawer__btn is-accent" data-id="UpdateDrawer-retry" onClick={() => st.onRetry()}>{tr("common.retry", "重试")}</button>}
              <button type="button" className="drawer__btn" data-id="UpdateDrawer-dismiss" onClick={() => updateDrawer.close()}>{tr("common.close", "关闭")}</button>
            </>
          ) : (
            <>
              <span className="drawer__foot-status is-done">{tr("updateDrawer.updatedLatest", "已更新到最新")}</span>
              <button type="button" className="drawer__btn is-accent" data-id="UpdateDrawer-finish" onClick={() => updateDrawer.close()}>{tr("common.done", "完成")}</button>
            </>
          )}
        </div>
      </div>
    </div>
  );
}

// ---------------------------------------------------------------------------
// Machine identity, declared rather than inferred.
//
// There was no reliable way to say WHICH machine you were looking at. The
// hostname is not one: "computer" is a Windows default and several boxes here
// answer to it. Nor is the egress IP — a whole site shares one. Nor the local
// cicy-code, because some of these machines have none installed at all. Acting
// on a guess means acting on the wrong machine, which is how one of them got
// taken down.
//
// So the team is stated once, at login, and stored next to the other machine
// facts. Everything downstream — the control channel's registration, the fleet
// listing, how a command is addressed — uses that and nothing else.
const TEAM_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
// A desktop's team is namespaced so it can never be confused with the
// cicy-code instance of the same name (the hub already lists both under one
// owner: `xs-1001` the instance, `desktop-…` the machine). Typed without the
// prefix, stored and shown with it.
const TEAM_PREFIX = "desktop-";
// 预填的默认账号:装机时不用手输。
const DEFAULT_EMAIL = "limeng9088@gmail.com";
const withTeamPrefix = (t) => (t && !t.startsWith(TEAM_PREFIX) ? TEAM_PREFIX + t : t || "");
// 显示/编辑时去掉前缀:存的是 desktop-xs-1009,给人看的是 xs-1009。
const stripTeamPrefix = (t) => String(t || "").replace(new RegExp("^" + TEAM_PREFIX), "");
// 机器名行内编辑:显示态(button)和编辑态(input)必须**完全同尺寸**,否则一点进编辑
// 整行会跳 —— 输入框比按钮多了 1px 边框和左右内边距。所以高度写死、box-sizing 用
// border-box、按钮也带一条透明边框把那 1px 占掉,两个状态逐像素对齐。
const TEAM_FIELD_STYLE = {
  flex: "1 1 0%", minWidth: 0, maxWidth: "100%", font: "inherit", boxSizing: "border-box",
  height: 22, lineHeight: "20px", padding: "0 6px",
  borderRadius: 6, border: "1px solid transparent",
};

// global.json is the main process's file; the page cannot touch it directly.
// The homepage bridge is the unguarded one, so this is a plain call. There is
// no bare `require` in the eval sandbox, and the window id is per-process —
// resolve it rather than assuming 1.
async function mainEval(code) {
  const g = await window.electronRPC?.("get_windows", {});
  const gt = ((g && g.content) || []).map((c) => c && c.text).join("");
  let wid = 1;
  try { const ws = JSON.parse(gt); if (ws && ws.length) wid = ws[0].id; } catch {}
  const r = await window.electronRPC?.("control_electron_BrowserWindow", { win_id: wid, code });
  return ((r && r.content) || []).map((c) => c && c.text).join("");
}

const READ_TOKEN = `(()=>{const r=process.mainModule.require.bind(process.mainModule);
const os=r("os"),fs=r("fs"),path=r("path");
try{const c=JSON.parse(fs.readFileSync(path.join(os.homedir(),"cicy-ai","global.json"),"utf8"));
return String((c.hubAuth||{}).token||"")}catch(e){return ""}})()`;

const READ_TEAM = `(()=>{const r=process.mainModule.require.bind(process.mainModule);
const os=r("os"),fs=r("fs"),path=r("path");
// Identity source, in order:
//  1) a numeric txt on the operator's Desktop (C:/Users/xs/Desktop/1007.txt) —
//     the machine's number, dropped there by whoever set the box up. This is the
//     ground truth; its filename is the id. Forward slashes only, so this string
//     survives a second eval (a backslash path breaks with "invalid hex escape").
//  2) ~/cicy-ai/team.txt, 3) global.json desktopTeam.
const home=os.homedir().replace(/\\\\/g,"/");
const deskDirs=["C:/Users/xs/Desktop","C:/Users/Intel/Desktop","C:/Users/Administrator/Desktop",home+"/Desktop"];
for(const d of deskDirs){try{for(const f of fs.readdirSync(d)){const m=/^(\\d{3,5})\\.txt$/i.exec(f);if(m)return "xs-"+m[1];}}catch(e){}}
const txts=[path.join(os.homedir(),"cicy-ai","team.txt")];
for(const f of txts){try{const t=String(fs.readFileSync(f,"utf8")).trim();if(t)return t}catch(e){}}
try{const c=JSON.parse(fs.readFileSync(path.join(os.homedir(),"cicy-ai","global.json"),"utf8"));
return String(c.desktopTeam||"")}catch(e){return ""}})()`;

// The stable per-machine id the hub client already keeps. Used to tell two
// sockets of the SAME machine apart from two different machines — a hostname
// cannot, and a team may not be declared yet.
// This machine's identity, generated by the page and kept in localStorage.
// desktop.cicy-ai.com is a separate origin per machine, so each box gets its
// own value on first load and keeps it -- unique with nothing to coordinate.
// It is fine for this to be ephemeral: if storage is cleared the box just
// re-enrols under the same team name, which the hub lets the owner reclaim.
// Replaces hubDesktopInstanceId, which was copied onto every cloned box and
// so collided across machines.
// A SHORT, speakable handle for this machine — 5 chars, no ambiguous 0/O/1/I/l.
// The operator reads it off the screen ("3F9K2 has a problem"); it addresses
// the client just like the long id. Persisted in localStorage.
function getShortId() {
  const abc = "23456789ABCDEFGHJKMNPQRSTUVWXYZ";
  const gen = () => Array.from({ length: 5 }, () => abc[Math.floor(Math.random() * abc.length)]).join("");
  try {
    let s = localStorage.getItem("cicy-short-id");
    if (!/^[2-9A-HJ-NP-Z]{5}$/.test(s || "")) { s = gen(); localStorage.setItem("cicy-short-id", s); }
    return s;
  } catch { return gen(); }
}

function getDesktopId() {
  const gen = () =>
    "code-desktop-" +
    ((typeof crypto !== "undefined" && crypto.randomUUID)
      ? crypto.randomUUID().replace(/-/g, "")
      : (Math.random().toString(16).slice(2) + Date.now().toString(16) + Math.random().toString(16).slice(2)).slice(0, 32));
  try {
    let id = localStorage.getItem("cicy-desktop-id");
    if (!/^code-desktop-[A-Za-z0-9]{16,}$/.test(id || "")) { id = gen(); localStorage.setItem("cicy-desktop-id", id); }
    return id;
  } catch {
    return gen();
  }
}

async function readTeam() {
  // Normalise to the desktop- namespace no matter the source: a team.txt often
  // holds a bare name (xs-1004), and it must read the same as one written
  // through the UI (desktop-xs-1004) so a machine is not listed twice under two
  // spellings of one identity.
  try { return withTeamPrefix((await mainEval(READ_TEAM)).trim()); } catch { return ""; }
}

// 写机器名。**必须写到 readTeam 真正会读的那个来源**,否则改了名却不生效:
// readTeam 的优先级是 1) 桌面上的 <数字>.txt  2) ~/cicy-ai/team.txt  3) global.json 的 desktopTeam。
// 以前这里只写 3),而机器自动起名时写的是 2)(team.txt),2 盖过 3 —— 界面显示改成功了,
// 上报给 hub 的还是老名字(实测:Mac 改成 mac,车队里仍是 WS5KS)。
// 所以 2) 和 3) 都写;清空时把 team.txt 一并删掉。
// 1) 是矩阵机的地面事实(桌面上放个 1005.txt 就叫 xs-1005),不在这里动它 ——
// 真有这个文件时下面会返回它,调用方据此提示改名没生效。
async function writeTeam(name) {
  name = withTeamPrefix(String(name || "").trim());
  const code = `(()=>{const r=process.mainModule.require.bind(process.mainModule);
const os=r("os"),fs=r("fs"),path=r("path");
const home=os.homedir();
const dir=path.join(home,"cicy-ai");
try{fs.mkdirSync(dir,{recursive:true})}catch(e){}
const want=${JSON.stringify(name)};
// 2) team.txt —— 优先级比 global.json 高,不写这个就等于没改
const tf=path.join(dir,"team.txt");
try{ if(want){fs.writeFileSync(tf,want)}else{try{fs.unlinkSync(tf)}catch(e){}} }catch(e){}
// 3) global.json
const p=path.join(dir,"global.json");
let c={};try{c=JSON.parse(fs.readFileSync(p,"utf8"))}catch(e){}
c.desktopTeam=want;
try{fs.writeFileSync(p,JSON.stringify(c,null,2))}catch(e){}
// 回读:按 readTeam 的真实优先级算出这台机器现在到底叫什么
const home2=home.replace(/\\\\/g,"/");
const deskDirs=["C:/Users/xs/Desktop","C:/Users/Intel/Desktop","C:/Users/Administrator/Desktop",home2+"/Desktop"];
for(const d of deskDirs){try{for(const f of fs.readdirSync(d)){const m=/^(\\d{3,5})\\.txt$/i.exec(f);if(m)return "xs-"+m[1];}}catch(e){}}
try{const t=String(fs.readFileSync(tf,"utf8")).trim();if(t)return t}catch(e){}
try{const c2=JSON.parse(fs.readFileSync(p,"utf8"));return String(c2.desktopTeam||"")}catch(e){return ""}
})()`;
  return withTeamPrefix((await mainEval(code)).trim());
}

export default function App() {
  useSelfHeal(); // disarm a build whose auto-install can loop (see useSelfHeal)
  useHomepageAutoReload(); // last-resort staleness check for a bundle too broken to hold a socket
  useFleetSocket(); // realtime control channel — the primary way in (see useFleetSocket)
  useHubControl(); // address-free control channel straight to ws.cicy-ai.com
  useAutoLogin(); // sign a not-yet-signed-in client in, no UI (see useAutoLogin)
  // First-run terms gate (合规第一道整体同意) — blocks the whole UI until
  // accepted. undefined = checking, false = must show gate, true = past it.
  // Distinct from the MITM CA opt-in; accepting terms never enables audit.
  const [termsOk, setTermsOk] = useState(undefined);
  useEffect(() => {
    if (!window.cicy?.terms?.status) { setTermsOk(true); return; } // no bridge (dev) → don't block
    window.cicy.terms.status(TERMS_VERSION)
      .then((r) => setTermsOk(!!r?.accepted))
      .catch(() => setTermsOk(true));
  }, []);

  // Tag <html> with the platform + fullscreen state so CSS can reserve the
  // macOS hiddenInset traffic-light gutter (the topbar's padding-left:84px rule
  // is gated on [data-platform="darwin"][data-fullscreen="0"]). Without this the
  // red/yellow/green buttons overlap the brand — the reported misalignment.
  useEffect(() => {
    const root = document.documentElement;
    try { root.setAttribute("data-platform", window.cicy?.platform || "linux"); } catch {}
    root.setAttribute("data-fullscreen", "0");
    let off;
    try {
      off = window.cicy?.window?.onFullscreen?.((fs) => root.setAttribute("data-fullscreen", fs ? "1" : "0"));
    } catch {}
    return () => { try { off && off(); } catch {} };
  }, []);

  // 全局兜底:任何 bootstrap/安装/修复进度(docker:app-progress)都让 drawer 可见 —— 不管是
  // 卡片按钮、程序触发、还是 renderer 重连后主进程还在跑。原则:后台不出日志 = 耍流氓。
  // 只接管「没人开 drawer」的情况(source==="auto"):某个 run*() 自己开的(source==="op")由它
  // 自己 push,这里不插手避免重复。"open" phase 是打开失败报告流,自管,跳过。
  useEffect(() => {
    if (!window.cicy?.docker?.onAppProgress) return;
    const unsub = window.cicy.docker.onAppProgress((ev) => {
      if (!ev || ev.phase === "open") return;
      if (!dockerDrawerState) dockerDrawer.open({ source: "auto" });
      if (dockerDrawerState?.source !== "auto") return; // 某 run*() 拥有 → 它 push,别重复
      if (ev.phase === "done" && (ev.status === "done" || ev.status === "reboot")) {
        dockerDrawer.finish({ ok: ev.status === "done", status: ev.status === "reboot" ? "reboot" : undefined, message: ev.message });
      } else if (ev.status === "error") {
        dockerDrawer.finish({ ok: false, message: ev.message });
      } else {
        dockerDrawer.push(ev);
      }
    });
    return () => { try { unsub && unsub(); } catch {} };
  }, []);

  // sk-xxx (LLM API). Used by /v1/chat/completions etc.
  const [token, setToken] = useState(() => safeGet(TOKEN_KEY));
  // Console-API bearer. Used by /api/user/self, /api/teams, etc.
  const [accessToken, setAccessToken] = useState(() => safeGet(ACCESS_TOKEN_KEY));
  // Required as `New-Api-User: <id>` header on every console-API call —
  // middleware.UserAuth() rejects requests without it.
  const [userId, setUserId] = useState(() => safeGet(USER_ID_KEY));
  // True while we ask main for a durably-saved login (origin-independent).
  // Prevents the login card from flashing on every launch before restore.
  const [authRestoring, setAuthRestoring] = useState(() => !safeGet(TOKEN_KEY));
  // 访客模式:跳过登录进入主界面(本地功能可用)。登录成功即退出访客态。
  // 主页不要求登录:没有 token 就是访客,本地/自定义团队全部可用;登录卡已移除
  // (LOGIN_UI=false)。已有 token 的老用户照常显示云端团队。
  const LOGIN_UI = false;
  const [guest, setGuest] = useState(true);
  const enterGuest = () => setGuest(true);
  const leaveGuest = () => setGuest(true);
  const [loggingIn, setLoggingIn] = useState(false);
  const [loginBusy, setLoginBusy] = useState(false); // login 请求在途:按钮 disable+loading,防重复点击,出错恢复
  const [loginUrl, setLoginUrl] = useState(""); // shown as a manual fallback when the browser doesn't auto-open
  // Email magic-link device-poll login (cross-device: the link works on a phone).
  const [email, setEmail] = useState(DEFAULT_EMAIL);
  // "" = not set yet (must be before login), null = still reading it.
  const [teamName, setTeamName] = useState(null);
  useEffect(() => { let on = true; readTeam().then((t) => on && setTeamName(t || "")); return () => { on = false; }; }, []);
  const [emailSent, setEmailSent] = useState(false); // true once the link email is sent → "等待点击" state
  const [error, setError] = useState("");
  const [welcome, setWelcome] = useState("");
  // Fetched after login: { id, display_name, username, email, ... }
  const [me, setMe] = useState(null);
  // Fetched after login: [{ id, title, team_kind, status, workspace_url, ... }]
  const [teams, setTeams] = useState(null);
  const [profileLoading, setProfileLoading] = useState(false);
  const [profileError, setProfileError] = useState("");
  // Guards the auto re-login so a dead session (/api/teams 401) triggers the
  // magic-link flow ONCE instead of looping. Reset when a fresh login lands.
  const reauthing = useRef(false);
  // Local teams discovered from ~/cicy-ai/global.json (main-process probe).
  const [localTeams, setLocalTeams] = useState(null);
  const [localTeamsLoading, setLocalTeamsLoading] = useState(false);
  // localTeamsFetched: true after the first probe completes (even if empty).
  // Used to distinguish "not yet probed" (unknown) from "probed and empty"
  // (cloud-only) in localHelperState below.
  const [localTeamsFetched, setLocalTeamsFetched] = useState(false);
  // 通用头像映射 { id: dataUrl } —— 本地/Docker/云端团队都按 id 取头像(云端团队不在
  // teams.json,所以单独拉一份映射,给所有卡片 + 打开 tab 时透传 avatar 用)。
  const [avatars, setAvatars] = useState({});
  const fetchAvatars = useCallback(async () => {
    try { const m = await window.cicy?.localTeams?.avatars?.(); setAvatars(m && typeof m === "object" ? m : {}); } catch {}
  }, []);
  // 「新加团队」下拉 + 自定义团队 modal: 点按钮出两个选项——自定义(本 modal 输 url/title)
  // 或私有云(跳云端团队中心)。自定义走 localTeams.add({base_url,name,api_token})。
  const [addMenuOpen, setAddMenuOpen] = useState(false);
  const [customOpen, setCustomOpen] = useState(false);
  const [customTitle, setCustomTitle] = useState("");
  const [customUrl, setCustomUrl] = useState("");
  const [customBusy, setCustomBusy] = useState(false);
  const [customErr, setCustomErr] = useState("");
  // Tab state for the team grid: "all" | "local" | "cloud" | "custom" | "hub".
  const [tab, setTab] = useState("all");
  // 车队 dsh 卡片(懒加载:进 dsh tab 才去拉 /api/dsh-list)。
  // 必须和其它 hook 一起放在**所有提前 return 之前** —— App 在条款/登录/恢复态有 4 个
  // early return(termsOk / !token 等),把 hook 放到它们后面会让登录前后 hook 数量不一致,
  // React 直接抛 #310 "Rendered more hooks than during the previous render",整页白屏。
  // 2026-09-13 我就是这么把全车队首页打崩的。
  const dshFleet = useDshFleet();
  // CiCy Hub: email sign-in → every cicy-code instance of that account (no local
  // cicy-code needed). State + cards live in useHub / HubInstanceCard below.
  const hub = useHub();

  // Pull /api/user/self + /api/teams in parallel using the access_token.
  // Goes through window.cicy.cloud.fetch — main does the actual request,
  // so we sidestep CORS (vite-dev localhost:8173 / file:// origins are
  // not on cicy-ai.com's allowlist).
  const fetchProfile = useCallback(async (at, uid) => {
    if (!at) return;
    if (!window.cicy?.cloud?.fetch) {
      setProfileError("cloud fetch bridge missing");
      return;
    }
    setProfileLoading(true);
    setProfileError("");
    // Cloud uses its own per-user session token (sk-sess-, from /cb) as the
    // SOLE Bearer for every console call. No New-Api-User header and no
    // access_token — those were the old new-api convention, dropped when owner
    // moved cloud to self-built identity (authM resolves owner from the session).
    const headers = { Authorization: `Bearer ${at}` };
    try {
      const [selfRes, teamsRes] = await Promise.all([
        window.cicy.cloud.fetch(`${CLOUD_BASE}/api/user/self`, { headers }),
        window.cicy.cloud.fetch(`${CLOUD_BASE}/api/teams`,     { headers }),
      ]);
      // Session DEAD (cloud invalidated the sk-sess token) → /api/teams 401. The
      // "永久登录" red line forbids re-prompting on mere restart/expiry, but a
      // GENUINE 401 means the session is gone — the only recovery is a fresh
      // login. Trigger the magic-link ONCE (guarded) instead of retrying forever.
      if (teamsRes?.status === 401) {
        if (!reauthing.current && window.cicy?.auth?.loginStart) {
          reauthing.current = true;
          setProfileError(tr("auth.sessionExpired", "会话已过期,正在重新登录…"));
          try { await window.cicy.auth.loginStart(); } catch {}
        }
        return;
      }
      // /api/teams drives the cloud team grid. On a transient failure (network /
      // 5xx) degrade SILENTLY: keep whatever teams we already have and let the
      // background sync retry — do NOT throw. 不把报错显示给用户,后台重试。
      // (Throwing here painted the homepage with a red "/api/teams …" error.)
      if (teamsRes?.ok) {
        const teamsBody = JSON.parse(teamsRes.body || "{}"); // bare: { teams: [...] }
        setTeams(Array.isArray(teamsBody?.teams) ? teamsBody.teams : []);
      } else {
        setTeams((t) => (t === null ? [] : t)); // 非 ok:首次加载也要 resolve,skeleton 才收场
      }
      // /api/user/self is best-effort: it only fills the profile display name.
      // A 404 / failure here must NOT block login or the team list (the cloud
      // endpoint can lag) — degrade to a null profile instead of throwing.
      // /api/user/self is wrapped: { success, message, data }
      if (selfRes?.ok) {
        try {
          const selfBody = JSON.parse(selfRes.body || "{}");
          setMe(selfBody?.success === false ? null : (selfBody?.data || null));
        } catch { setMe(null); }
      } else {
        setMe(null);
      }
    } catch (e) {
      // 不把报错显示给用户 —— 一个瞬时的云端/网络失败不该让首页飘红;后台
      // refreshCloudTeams 会按周期静默重试。清掉任何残留的错误态。
      setProfileError("");
      setTeams((t) => (t === null ? [] : t)); // 首次加载失败也让 teams resolve → skeleton 收场
    } finally {
      setProfileLoading(false);
    }
  }, []);

  // 私有云/云端团队信息持续同步:/api/teams 原本只在登录/挂载拉一次,所以 dash 上改了
  // 私有云的 host_url / 名字 / 状态,桌面看不到。用 ref 持有当前 bearer,挂到对账循环里
  // 按窗口可见性周期重拉(轻量:只 setTeams,不动 loading/self,不在 401 时清空列表)。
  const bearerRef = useRef("");
  useEffect(() => { bearerRef.current = token || accessToken || ""; }, [token, accessToken]);
  const refreshCloudTeams = useCallback(async () => {
    const at = bearerRef.current;
    if (!at || !window.cicy?.cloud?.fetch) return;
    try {
      const r = await window.cicy.cloud.fetch(`${CLOUD_BASE}/api/teams`, { headers: { Authorization: `Bearer ${at}` } });
      if (r?.ok) { const b = JSON.parse(r.body || "{}"); if (Array.isArray(b?.teams)) setTeams(b.teams); }
    } catch {}
  }, []);

  // 云端团队改名:PATCH /api/teams/<id> {title}(cicy-cloud-v1 handleTeamByID,
  // owner-only,bump title_version)。成功后重拉 teams 让新名字落地。让私有云卡也能
  // 改名,和本地/Docker 卡一致——区别是这个改的是云端、会同步到所有设备。
  const renameCloudTeam = useCallback(async (id, title) => {
    const at = bearerRef.current;
    const next = String(title || "").trim();
    if (!at || !window.cicy?.cloud?.fetch || !next) return { ok: false, error: "no session / empty" };
    try {
      const r = await window.cicy.cloud.fetch(`${CLOUD_BASE}/api/teams/${id}`, {
        method: "PATCH",
        headers: { Authorization: `Bearer ${at}`, "Content-Type": "application/json" },
        body: JSON.stringify({ title: next }),
      });
      if (r?.ok) { await refreshCloudTeams(); return { ok: true }; }
      return { ok: false, error: `${r?.status || "?"} ${r?.error || ""}` };
    } catch (e) { return { ok: false, error: e.message }; }
  }, [refreshCloudTeams]);

  // 改私有云的访问地址(host_url)—— 和改名同源,走云端 PATCH /api/teams/<id> {host_url}。
  // 成功后重拉云端团队,卡片地址即时更新(私有云可以改 url)。
  const updateCloudTeamUrl = useCallback(async (id, hostUrl) => {
    const at = bearerRef.current;
    const next = String(hostUrl || "").trim();
    if (!at || !window.cicy?.cloud?.fetch || !next) return { ok: false, error: "no session / empty" };
    try { new URL(next); } catch { return { ok: false, error: "地址格式不对(需 http(s)://…)" }; }
    try {
      // 云端 PATCH 收的是 **hostUrl(驼峰)**,虽然 GET /api/teams 返回的是 host_url(下划线)——
      // 实测过:host_url 会被忽略(400 nothing_to_update),hostUrl 才真正改(200)。
      const r = await window.cicy.cloud.fetch(`${CLOUD_BASE}/api/teams/${id}`, {
        method: "PATCH",
        headers: { Authorization: `Bearer ${at}`, "Content-Type": "application/json" },
        body: JSON.stringify({ hostUrl: next }),
      });
      if (r?.ok) { await refreshCloudTeams(); return { ok: true }; }
      return { ok: false, error: `${r?.status || "?"} ${r?.error || ""}` };
    } catch (e) { return { ok: false, error: e.message }; }
  }, [refreshCloudTeams]);

  // 删除私有云团队:确认后走云端 DELETE /api/teams/{id},成功后重拉云端列表 + toast。
  const deleteCloudTeam = useCallback(async (team) => {
    const at = bearerRef.current;
    if (!at || !window.cicy?.cloud?.fetch) { toast.show({ message: tr("common.noAuth", "未登录"), status: "error", ttl: 5000 }); return; }
    try {
      const r = await window.cicy.cloud.fetch(`${CLOUD_BASE}/api/teams/${team.id}`, {
        method: "DELETE", headers: { Authorization: `Bearer ${at}` },
      });
      if (r?.ok || r?.status === 200 || r?.status === 204) {
        toast.show({ message: tr("teamCard.deleted", "团队已删除"), status: "done", ttl: 3000 });
        await refreshCloudTeams();
      } else {
        toast.show({ message: `${tr("teamCard.deleteFailed", "删除失败")}: ${r?.status || "?"} ${r?.error || ""}`, status: "error", ttl: 7000 });
      }
    } catch (e) { toast.show({ message: `${tr("teamCard.deleteFailed", "删除失败")}: ${e.message}`, status: "error", ttl: 7000 }); }
  }, [refreshCloudTeams]);

  // First profile fetch on mount. The cloud console endpoints (/api/user/self,
  // /api/teams) authenticate the owner-bound LOGIN token (the sk-xxx from the
  // /cb callback) — NOT the console access_token (the cloud never mints one;
  // sending it 401s). Prefer the login token; fall back to access_token only if
  // somehow that's all we have.
  useEffect(() => {
    const bearer = token || accessToken;
    if (bearer) fetchProfile(bearer, userId);
    // 没登录:teams 不会有 fetchProfile 去 resolve,直接置空 —— 否则 teams 恒 null →
    // firstLoading(localTeams===null || teams===null)永真 → 团队/Hub 区一直转 skeleton。
    else setTeams((t) => (t === null ? [] : t));
  }, [token, accessToken, userId, fetchProfile]);

  // Local teams: probe on mount (independent of cloud login — local team
  // discovery doesn't require a token). Fast-poll every 3s for the first
  // 30s so we catch cicy-code coming online shortly after desktop launch,
  // then settle to 30s.
  const fetchLocalTeams = useCallback(async () => {
    if (!window.cicy?.localTeams?.list) return;
    setLocalTeamsLoading(true);
    try {
      // 把**当前登录账号**(渲染层持久化的权威 userId)传给 main 的 list() —— 让它按这个账号
      // backfill 老团队归属 + 过滤,而不是让 main 自己去 global.json 猜(可能 stale/空 → 漏过滤)。
      const list = await window.cicy.localTeams.list({ refresh: true, uid: safeGet(USER_ID_KEY) || "" });
      setLocalTeams(Array.isArray(list) ? list : []);
    } catch {
      setLocalTeams([]);
    } finally {
      setLocalTeamsLoading(false);
      setLocalTeamsFetched(true);
    }
  }, []);
  // 自定义团队:输 url(必填)+ title + 可选 token → localTeams.add(upsert by base_url)。
  // 成功后关 modal、刷新、切到「自定义」tab。出错就地显示、可重试,按钮全程 disable+loading。
  async function submitCustom() {
    if (customBusy) return;
    const url = customUrl.trim();
    if (!url) { setCustomErr(tr("teams.urlRequired", "请输入地址 URL")); return; }
    try { new URL(url); } catch { setCustomErr(tr("teams.badUrl", "URL 无效(需含 http(s)://)")); return; }
    setCustomErr(""); setCustomBusy(true);
    try {
      const r = await window.cicy.localTeams.add({ base_url: url, name: customTitle.trim(), failIfExists: true });
      if (!r || r.ok === false) { setCustomErr(r?.error === "exists" ? tr("teams.urlExists", "该地址已存在") : humanError(r?.error || "add failed")); return; }
      // 先刷新列表(loading 全程覆盖到新卡出现),成功后再关 modal —— 否则关了之后到卡片
      // 刷出来之间那段没 loading。
      await fetchLocalTeams();
      setCustomTitle(""); setCustomUrl(""); setCustomOpen(false);
    } catch (e) {
      setCustomErr(humanError(e?.message || String(e)));
    } finally {
      setCustomBusy(false);
    }
  }
  // Rename a local team: persist via localTeams.update then refresh the list.
  // Empty name falls back to 未命名 (mirrors local-teams.addTeam default).
  const renameLocalTeam = useCallback(async (id, name) => {
    if (!window.cicy?.localTeams?.update) return { ok: false, error: "no_bridge" };
    let r;
    try {
      r = await window.cicy.localTeams.update(id, { name: String(name || "").trim() || tr("localTeams.unnamed", "未命名") });
    } catch (e) { r = { ok: false, error: e?.message || String(e) }; }
    await fetchLocalTeams();   // 对账:props 追上后清乐观名
    return r || { ok: false, error: "no_result" };
  }, [fetchLocalTeams]);
  // 自适应对账:窗口可见时 ~3s 拉一次云端 title(远端/dash 改名秒级可见),隐藏时
  // 退避到 30s 只刷新本地(云端对账交给 main 进程 30s 兜底);切回可见/聚焦立即对账。
  useEffect(() => {
    let timer;
    let stopped = false;
    // 3s 对账 = 每分钟几十个新 TCP 连接(每个团队一个云端请求),Windows 上 TIME_WAIT 堆到上万把
    // 动态端口耗尽(实测 15165 个 TIME_WAIT → 连 127.0.0.1:8008 都 EADDRINUSE)。改 30s/120s,
    // 切回可见/聚焦仍立即对账一次。
    const VISIBLE_MS = 30_000;
    const HIDDEN_MS = 120_000;

    // 一发对账:本地 title 拉进 teams.json + 刷新本地列表 + 重拉云端团队(私有云
    // host_url/名字/状态的同步)。三件事并行。
    const reconcile = async () => {
      try { await window.cicy?.localTeams?.syncCloud?.(); } catch {}
      await Promise.all([fetchLocalTeams(), refreshCloudTeams(), fetchAvatars()]);
    };

    const schedule = () => {
      if (stopped) return;
      const visible = document.visibilityState === "visible";
      timer = setTimeout(async () => {
        if (document.visibilityState === "visible") await reconcile();
        else await fetchLocalTeams();           // 隐藏:只刷新本地,不打云端
        schedule();
      }, visible ? VISIBLE_MS : HIDDEN_MS);
    };

    reconcile();        // 挂载即来一发
    schedule();

    // 切回可见/聚焦 → 立即对账(dash 改完名点回桌面秒同步)
    const onWake = () => { if (document.visibilityState === "visible") reconcile(); };
    document.addEventListener("visibilitychange", onWake);
    window.addEventListener("focus", onWake);

    return () => {
      stopped = true; clearTimeout(timer);
      document.removeEventListener("visibilitychange", onWake);
      window.removeEventListener("focus", onWake);
    };
  }, [fetchLocalTeams, refreshCloudTeams]);

  // Webview relay — the Team Helper <webview> calls window.cicy.localTeams.add(...)
  // inside the webview, that hops main → here. We run the actual IPC, refresh
  // UI right away, and reply with the result so the webview's await resolves.
  useEffect(() => {
    if (!window.cicy?.localTeams?.onWebviewRelay) return;
    const unsub = window.cicy.localTeams.onWebviewRelay(async ({ reqId, msg }) => {
      let result = { ok: false, error: "unknown relay type" };
      try {
        if (msg?.type === "localTeams:add") {
          result = await window.cicy.localTeams.add(msg.spec || {});
        } else if (msg?.type === "localTeams:remove") {
          result = await window.cicy.localTeams.remove(msg.id);
        } else if (msg?.type === "localTeams:update") {
          result = await window.cicy.localTeams.update(msg.id, msg.patch || {});
        } else if (msg?.type === "localTeams:upgrade") {
          result = await window.cicy.localTeams.upgrade(msg.id);
        } else if (msg?.type === "localTeams:list") {
          result = { ok: true, teams: await window.cicy.localTeams.list({ refresh: true }) };
        }
        // (sidecar:install / sidecar:checkLatest removed — cicy-code is now
        // installed via `npx cicy-code` by the sidecar, no in-app downloader.)
        // Force-refresh the team list so the new/removed/upgraded card
        // shows up before the next 30 s poll.
        fetchLocalTeams();
      } catch (e) {
        result = { ok: false, error: e?.message || String(e) };
      }
      try { window.cicy.localTeams.replyWebviewRelay(reqId, result); } catch {}
    });
    return () => { try { unsub?.(); } catch {} };
  }, [fetchLocalTeams]);

  const openLocalTeam = useCallback(async (teamId) => {
    if (!window.cicy?.localTeams?.open) return;
    try { await window.cicy.localTeams.open(teamId); } catch {}
  }, []);

  // (USER_CONTEXT push retired — cicy-code 2.1.7's --helper mode fires the
  // open-protocol trigger server-side from watchHelperOpencodeReadyAndKick,
  // gated on BOTH opencode-ready AND a connected web-* chat client. That
  // eliminates the race we used to paper over with a 6 s renderer-side
  // timeout, and stops the "刷屏" double-greet whenever the webview's
  // did-finish-load fired more than once per session. Language detection now
  // happens INSIDE the agent via `agent-webpage exec-js navigator.language`
  // against the same client — see AGENTS.md.)

  // Restore login from the main-process durable store when THIS origin's
  // localStorage has none. The homepage origin drifts (file:// / the team
  // domain / an IP:port), and localStorage is origin-scoped, so without this a
  // token saved under a previous origin would force a needless re-login. Main
  // persists the login origin-independently (global.json); we adopt it here so
  // "logged in once" stays valid until an explicit logout.
  useEffect(() => {
    if (safeGet(TOKEN_KEY)) { setAuthRestoring(false); return; }
    if (!window.cicy?.auth?.getSaved) { setAuthRestoring(false); return; }
    let cancelled = false;
    (async () => {
      try {
        const saved = await window.cicy.auth.getSaved();
        if (cancelled) return;
        if (saved?.token) {
          try { localStorage.setItem(TOKEN_KEY, saved.token); } catch {}
          setToken(saved.token);
          if (saved.accessToken) {
            try { localStorage.setItem(ACCESS_TOKEN_KEY, saved.accessToken); } catch {}
            setAccessToken(saved.accessToken);
          }
          if (saved.userId) {
            try { localStorage.setItem(USER_ID_KEY, String(saved.userId)); } catch {}
            setUserId(String(saved.userId));
          }
        }
      } catch {}
      finally { if (!cancelled) setAuthRestoring(false); }
    })();
    return () => { cancelled = true; };
  }, []);

  // auth:complete from main.
  useEffect(() => {
    if (!window.cicy?.auth?.onComplete) return;
    return window.cicy.auth.onComplete((payload) => {
      setLoggingIn(false);
      setEmailSent(false); // either flow completing clears the email "等待点击" state
      if (payload?.error) {
        setError(humanError(payload.error));
        return;
      }
      if (payload?.token) {
        // Fresh session landed — clear the dead-session re-auth guard + message
        // so a future 401 can re-trigger recovery.
        reauthing.current = false;
        setProfileError("");
        try { localStorage.setItem(TOKEN_KEY, payload.token); } catch {}
        setToken(payload.token);
        try { localStorage.removeItem(GUEST_KEY); } catch {}
        if (payload.accessToken) {
          try { localStorage.setItem(ACCESS_TOKEN_KEY, payload.accessToken); } catch {}
          setAccessToken(payload.accessToken);
        }
        if (payload.userId) {
          try { localStorage.setItem(USER_ID_KEY, String(payload.userId)); } catch {}
          setUserId(String(payload.userId));
        }
        setError("");
        setWelcome(payload.reused ? tr("auth.welcomeBack", "已恢复你之前的登录") : tr("auth.loginSuccess", "登录成功"));
        setTimeout(() => setWelcome(""), 3000);
      }
    });
  }, []);

  async function handleLogin() {
    if (loginBusy || loggingIn) return; // 防重复点击
    if (!window.cicy?.auth?.loginStart) {
      setError("auth bridge missing");
      return;
    }
    // team 名不再是登录的前置条件:留空照样能登录,登录后随时在账号菜单的「机器名」
    // 那行改(以前必须先起名才让登录,装机时很烦)。留空 = 不动机器上已有的名字,
    // 绝不写空把它清掉。只有真填了才校验格式并保存。
    const declared = String(teamName || "").trim();
    if (declared && !TEAM_RE.test(declared)) {
      setError(tr("auth.teamBadFormat", "team 名只能用字母、数字和 . _ -"));
      return;
    }
    if (declared) {
      try { await writeTeam(declared); } catch { setError("无法保存 team 名"); return; }
    }
    setError("");
    setLoginBusy(true); // 按钮 disable + loading
    try {
      const r = await window.cicy.auth.loginStart();
      if (!r?.ok) { setError(humanError(r?.error || "login start failed")); return; } // 出错:loginBusy 在 finally 恢复
      setLoginUrl(r.url || "");
      setLoggingIn(true); // 成功才切到"等待浏览器"视图
    } catch (e) {
      setError(humanError(e?.message || String(e)));
    } finally {
      setLoginBusy(false); // 无论成败都恢复(成功后视图已切走;失败/异常则按钮可再点)
    }
  }

  // Email magic-link device-poll login. Sends the link, then waits for the user
  // to click it on ANY device (the desktop polls the cloud) — fixes the loopback's
  // "clicked on my phone → desktop hangs" problem.
  async function handleEmailLogin() {
    if (loginBusy) return; // 防重复点击(连点会发多封邮件)
    if (!window.cicy?.auth?.emailLoginStart) {
      setError("auth bridge missing");
      return;
    }
    const addr = email.trim();
    if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(addr)) {
      setError(tr("auth.badEmail", "请输入有效的邮箱地址"));
      return;
    }
    // team 名不再是登录的前置条件:留空照样能登录,登录后随时在账号菜单的「机器名」
    // 那行改(以前必须先起名才让登录,装机时很烦)。留空 = 不动机器上已有的名字,
    // 绝不写空把它清掉。只有真填了才校验格式并保存。
    const declared = String(teamName || "").trim();
    if (declared && !TEAM_RE.test(declared)) {
      setError(tr("auth.teamBadFormat", "team 名只能用字母、数字和 . _ -"));
      return;
    }
    if (declared) {
      try { await writeTeam(declared); } catch { setError("无法保存 team 名"); return; }
    }
    setError("");
    setLoginBusy(true); // 按钮 disable + loading
    try {
      const r = await window.cicy.auth.emailLoginStart(addr);
      if (!r?.ok) { setError(humanError(r?.error || "email login failed")); return; }
      setEmailSent(true);
    } catch (e) {
      setError(humanError(e?.message || String(e)));
    } finally {
      setLoginBusy(false); // 出错恢复,可重试
    }
  }

  function handleLogout() {
    try {
      localStorage.removeItem(TOKEN_KEY);
      localStorage.removeItem(ACCESS_TOKEN_KEY);
      localStorage.removeItem(USER_ID_KEY);
    } catch {}
    // Clear the durable main-process store too — explicit logout is the ONLY
    // path that should invalidate the persisted login.
    try { window.cicy?.auth?.logout?.(); } catch {}
    setToken(null);
    setAccessToken(null);
    setUserId(null);
    setMe(null);
    setTeams(null);
    setError("");
    setProfileError("");
  }

  // First-run terms gate takes precedence over everything (even login) —
  // accepting the terms is a precondition to using the software at all.
  if (termsOk === undefined) {
    return (
      <div className="shell" data-id="TermsCheckingSplash">
        <div className="glow" aria-hidden />
        <div className="card"><Brand /><div className="spinner-row"><Spinner /></div></div>
      </div>
    );
  }
  if (!termsOk) {
    return <FirstRunTermsGate onAgree={() => setTermsOk(true)} />;
  }

  // Still checking the durable store — show a minimal splash, not the login
  // card, so we never flash "please log in" before restore completes.
  if (!token && authRestoring) {
    return (
      <div className="shell" data-id="AuthRestoringSplash">
        <div className="glow" aria-hidden />
        <div className="card">
          <Brand />
          <div className="spinner-row"><Spinner /><span>{tr("auth.restoring", "正在恢复登录…")}</span></div>
        </div>
      </div>
    );
  }

  // Not logged in yet → centered login card (unless the user chose to continue
  // as a guest: local-only features, login available from the header).
  if (LOGIN_UI && !token && !guest) {
    return (
      <div className="shell">
        <div className="glow" aria-hidden />
        <div className="card">
          <Brand />
          {!loggingIn && !emailSent && (
            <>
              <p className="tagline">{tr("auth.tagline", "登录以同步你的团队、配置与 AI 助手")}</p>
              {/* Email magic-link is the PRIMARY login: it's cross-device safe (the
                  link works when clicked on a phone). The browser/loopback login is
                  demoted to a secondary option below — its link 302s to 127.0.0.1 and
                  only completes on the SAME machine, so a phone click breaks it. */}
              <label className="login-label" data-id="TeamNameLabel" htmlFor="cicy-team-name">
                {tr("auth.teamLabel", "本机 team 名(可选,登录后可在账号菜单里改)")}
              </label>
              <input
                id="cicy-team-name"
                type="text"
                className="login-email-input"
                data-id="TeamNameInput"
                value={teamName || ""}
                onChange={(e) => setTeamName(e.target.value)}
                placeholder={tr("auth.teamPlaceholder", "例如 xs-1001")}
                spellCheck={false}
                autoFocus
              />
              <label className="login-label" data-id="EmailLoginLabel" htmlFor="cicy-email-login">
                {tr("auth.emailLabel", "输入邮箱,用邮件链接登录")}
              </label>
              <input
                id="cicy-email-login"
                type="email"
                className="login-email-input"
                data-id="EmailLoginInput"
                value={email}
                onChange={(e) => setEmail(e.target.value)}
                onKeyDown={(e) => { if (e.key === "Enter") handleEmailLogin(); }}
                placeholder={tr("auth.emailPlaceholder", "you@example.com")}
                autoComplete="email"
                spellCheck={false}
              />
              <button className="btn-primary" data-id="EmailLoginSubmit" onClick={handleEmailLogin} disabled={loginBusy}>
                {loginBusy
                  ? <><Spinner /><span>{tr("auth.sending", "发送中…")}</span></>
                  : <><span>{tr("auth.emailLogin", "发送登录链接")}</span><ArrowIcon /></>}
              </button>
              <p className="hint">{tr("auth.emailHint", "手机上点邮件里的链接,也能登录这台电脑")}</p>
              <div className="login-divider" data-id="LoginDivider"><span>{tr("auth.or", "或")}</span></div>
              <button className="btn-ghost" data-id="BrowserLoginBtn" onClick={handleLogin} disabled={loginBusy}>
                {loginBusy ? tr("auth.opening", "打开中…") : tr("auth.browserLogin", "用浏览器登录(Google / SSO,仅同一台电脑)")}
              </button>
              {/* 不登录也能用本地能力:自定义团队(连已有 cicy-code 地址)、本地 Docker 团队。 */}
              <button type="button" className="btn-ghost" data-id="SkipLoginBtn" onClick={enterGuest} disabled={loginBusy}
                style={{ marginTop: 8, opacity: .85 }}>
                {tr("auth.skipLogin", "先不登录，添加自定义团队")}
              </button>
              <p className="hint" data-id="SkipLoginHint">{tr("auth.skipLoginHint", "不登录只能使用本地功能；云端团队、账单等需登录后使用")}</p>
            </>
          )}
          {emailSent && (
            <>
              <p className="tagline" data-id="EmailSentTagline">{tr("auth.emailSentTagline", "登录邮件已发送")}</p>
              <p className="hint" data-id="EmailSentHint" style={{ textAlign: "center" }}>
                {tr("auth.emailSentHint", "到邮箱打开链接即可（手机、电脑都行），这里会自动登录。")}
                <br />{email}
              </p>
              <div className="spinner-row">
                <Spinner />
                <span>{tr("auth.emailWaiting", "等待确认…")}</span>
              </div>
              <button className="btn-ghost" data-id="EmailSentCancel" onClick={() => {
                window.cicy?.auth?.emailLoginCancel?.();
                setEmailSent(false);
              }}>{tr("auth.cancel", "取消")}</button>
            </>
          )}
          {loggingIn && (
            <>
              <p className="tagline">{tr("auth.waitingTagline", "已在浏览器打开登录页，等待你完成…")}</p>
              <div className="spinner-row">
                <Spinner />
                <span>{tr("auth.waitingCallback", "等待回调")}</span>
              </div>
              {loginUrl && (
                <div className="login-fallback" data-id="LoginUrlFallback" style={{ marginTop: 10, textAlign: "center" }}>
                  <p className="hint" style={{ marginBottom: 6 }}>{tr("auth.browserNotOpened", "浏览器没自动打开?")}</p>
                  <button className="btn-ghost" data-id="LoginUrlFallback-open"
                    onClick={() => { try { window.cicy?.shell?.openExternal?.(loginUrl); } catch {} }}>{tr("auth.openManually", "手动打开登录页")}</button>
                  <button className="btn-ghost" data-id="LoginUrlFallback-copy"
                    onClick={() => { try { navigator.clipboard?.writeText(loginUrl); setWelcome(tr("auth.linkCopied", "链接已复制,粘到浏览器打开")); setTimeout(() => setWelcome(""), 2500); } catch {} }}>{tr("auth.copyLink", "复制链接")}</button>
                  <p className="hint" data-id="LoginUrlFallback-url" style={{ wordBreak: "break-all", marginTop: 6, fontSize: 11, opacity: 0.7, userSelect: "text" }}>{loginUrl}</p>
                </div>
              )}
              <button className="btn-ghost" onClick={() => {
                window.cicy?.auth?.loginCancel?.();
                setLoggingIn(false);
                setLoginUrl("");
              }}>{tr("auth.cancel", "取消")}</button>
            </>
          )}
          {error && <div className="error">{error}</div>}
        </div>
      </div>
    );
  }

  // Logged in: unified tabs + cards grid on the left, full-height webview
  // drawer on the right.
  // The Docker-版 cicy-code on :8008 has its own dedicated <DockerCard> (right of
  // the local card), so pull it out of the generic node list — else it'd ALSO
  // render as a 自定义 card (the bootstrap registers it as a team for the
  // token-injected 打开/刷新 flow).
  const dockerTeam = (localTeams || []).find((t) => isDockerApp(t.base_url)) || null;
  // Docker 现在是个普通云端 team(POST /api/teams,kind=cloud),title/改名与其它团队
  // 完全同一套:按 cloud_team_id 在 teams(refreshCloudTeams 周期刷新)里找到它的云端
  // team → 读 title(云端→本地自动同步,同节奏)、改名走 renameCloudTeam(PATCH)。
  const dockerCloudTeam = (dockerTeam && dockerTeam.cloud_team_id)
    ? (teams || []).find((t) => String(t.teamId || t.id) === String(dockerTeam.cloud_team_id))
    : null;
  // Map a cloud_team_id → the team's short non-guessable URL code (from /api/teams),
  // used for the 账单 deeplink so the URL isn't an enumerable sequential id.
  const cloudCodeFor = (cloudTeamId) => {
    if (!cloudTeamId) return null;
    const ct = (teams || []).find((t) => String(t.teamId || t.id) === String(cloudTeamId));
    return (ct && ct.code) || null;
  };
  // Split the cicyDesktopNodes list into 本地 (the localhost:8008 sidecar the
  // desktop owns — full lifecycle) vs 自定义 (deeplink-added nodes, usually
  // remote — probe-only, no restart/stop/update, just 打开).
  const localList  = (localTeams || []).filter((t) => isLocalSidecar(t.base_url));
  const customList = (localTeams || []).filter((t) => !isLocalSidecar(t.base_url) && !isDockerApp(t.base_url));
  // Windows 的本地团队是 Docker :8008(dockerTeam,单独 <DockerCard> 渲染,不在 localList),
  // 所以「本地」计数要把它算上 —— 否则 Windows 明明有 1 个本地却显示 0。dockerTeam 只在 Windows
  // 有值(isDockerApp win32-only),mac/linux 恒 null,不影响。
  const localCount = localList.length + (dockerTeam ? 1 : 0);
  const customCount = customList.length;
  // /api/teams returns ALL of this owner's teams — including kind=local ones
  // (this device's AND other devices'). On the desktop the 云端 tab must show
  // ONLY cloud teams; local teams come from the local store (localList) and
  // cross-device local aggregation belongs to the web dash, not here.
  // 不展示「共享」团队(共享 = 非私有云 且 非个人)。只留 私有云 / 个人。
  const cloudList = (teams || []).filter((t) => !t.is_local && t.kind !== "local"
    && (t.kind === "private" || t.team_kind === "personal"));
  const cloudCount = cloudList.length;
  const showLocal = tab === "all" || tab === "local";
  const showCustom = tab === "all" || tab === "custom";
  const showCloud = false; // 首页只用 CiCy Hub 账号,云端团队不再展示
  const showHub = tab === "all" || tab === "hub";
  const hubCount = hub.instances ? hub.instances.length : 0;
  // 「全部」只放真能打开的 dsh;DSH tab 里才把未就绪的一并列出来看状态。
  const dshReady = (dshFleet.list || []).filter((m) => m.ready);
  const showDshAll = tab === "all" || tab === "dsh";
  // 首次打开:本地或云端团队任一还没拉到(为 null)→ grid 显示 skeleton 占位卡,直到两边都
  // resolve(出错也 resolve 成 []),再显示真实内容 —— 避免一个先回来另一个还空的露馅。
  const firstLoading = localTeams === null || teams === null;

  return (
    <div className="shell shell--app">
      <div className="glow glow--app" aria-hidden />
      <div className="shell__left">
      <Header me={me} welcome={welcome} onLogout={handleLogout} hub={hub}
        guest={!token && guest} onLogin={leaveGuest}
        mitmTeam={localList.length > 0 ? localList[0] : null} />
      <UpdateBanner />
      <main className="main">
        {/* 整行:左边 tab 药丸,右边「新加团队」顶到行尾 */}
        <div className="app__tabsrow">
          <div className="app__tabs">
            {[
              { k: "all",    label: tr("teamFilter.all", "全部"),   n: localCount + customCount + hubCount + dshReady.length },
              { k: "local",  label: tr("teamFilter.local", "本地"),   n: localCount },
              { k: "hub",    label: tr("teamFilter.hub", "CiCy Hub"), n: hubCount },
              { k: "custom", label: tr("teamFilter.custom", "自定义"), n: customCount },
              { k: "dsh",    label: tr("teamFilter.dsh", "DSH"), n: dshReady.length },
            ].map(({ k, label, n }) => (
              <button
                key={k}
                type="button"
                className={`app__tab ${tab === k ? "is-active" : ""}`}
                onClick={() => setTab(k)}
              >
                {label}
                <span className="app__tab-count">{n}</span>
              </button>
            ))}
          </div>
          {/* DSH tab 的手动刷新:token 会随 dsh 重启而变,但不值得轮询 —— 要新的自己点。 */}
          {tab === "dsh" && (
            <button type="button" className="btn-ghost" data-id="DshFleetRefresh" disabled={dshFleet.busy}
              title={tr("dshFleet.refresh", "刷新 dsh 中继状态")}
              style={{ marginLeft: 8, width: 28, height: 28, padding: 0, display: "inline-flex", alignItems: "center", justifyContent: "center" }}
              onClick={() => dshFleet.refresh()}>
              {dshFleet.busy ? <Spinner /> : <RefreshIcon />}
            </button>
          )}
          {/* DesktopIdBadge 已移入用户头像下拉菜单(受信任站点上面)。此处仅留占位撑开右侧布局。 */}
          <span style={{ marginLeft: "auto" }} aria-hidden />
          {/* 行尾:新加团队 → 直接去云端团队中心添加(私有云)。自定义入口已删。 */}
          <div data-id="AddTeamWrap" style={{ position: "relative" }}>
            <button
              type="button"
              data-id="AddTeamButton"
              className="app__add-team"
              title={tr("teams.addHint", "添加团队")}
              onClick={() => setAddMenuOpen((v) => !v)}
            >
              + {tr("teams.add", "新加团队")}
            </button>
            {addMenuOpen && (
              <>
                <div onClick={() => setAddMenuOpen(false)} style={{ position: "fixed", inset: 0, zIndex: 40 }} />
                <div data-id="AddTeamMenu" role="menu" style={{ position: "absolute", top: "calc(100% + 6px)", right: 0, zIndex: 41, minWidth: 240, background: "var(--card, #1b1d22)", border: "1px solid var(--border, #2c2f36)", borderRadius: 10, boxShadow: "0 10px 30px rgba(0,0,0,.45)", overflow: "hidden" }}>
                  <button type="button" data-id="AddTeamMenu-custom" className="bcard__menu-item" style={{ display: "block", width: "100%", textAlign: "left", padding: "11px 14px", border: "none", background: "transparent", cursor: "pointer", color: "inherit" }}
                    onClick={() => { setAddMenuOpen(false); setCustomErr(""); setCustomOpen(true); }}>
                    <div style={{ fontSize: 13, fontWeight: 600 }}>{tr("teams.addCustom", "自定义团队")}</div>
                    <div style={{ fontSize: 11, opacity: .6, marginTop: 2 }}>{tr("teams.addCustomSub", "手动输入地址和名称(只存本地)")}</div>
                  </button>
                </div>
              </>
            )}
          </div>
        </div>

        {/* 自定义团队 modal:输 名称 + 地址 URL + 可选 token → localTeams.add */}
        {customOpen && (
          <div data-id="CustomTeamModal" style={{ position: "fixed", inset: 0, zIndex: 60, display: "flex", alignItems: "center", justifyContent: "center", background: "rgba(0,0,0,.5)" }}
            onMouseDown={(e) => { if (!customBusy && e.target === e.currentTarget) setCustomOpen(false); }}>
            <div onClick={(e) => e.stopPropagation()} style={{ width: 400, maxWidth: "92vw", background: "var(--card, #1b1d22)", border: "1px solid var(--border, #2c2f36)", borderRadius: 14, padding: 22, boxShadow: "0 20px 60px rgba(0,0,0,.5)" }}>
              <div style={{ fontSize: 16, fontWeight: 700, marginBottom: 4 }}>{tr("teams.customTitle", "添加自定义团队")}</div>
              <div style={{ fontSize: 12, opacity: .6, marginBottom: 16 }}>{tr("teams.customSub", "连接到一个已有的 cicy-code 地址")}</div>

              <label style={{ display: "block", fontSize: 12, opacity: .75, marginBottom: 6 }}>{tr("teams.customNameLabel", "名称")}</label>
              <input data-id="CustomTeamModal-title" className="login-email-input" style={{ width: "100%", marginBottom: 14 }}
                value={customTitle} onChange={(e) => setCustomTitle(e.target.value)} placeholder={tr("teams.customNamePlaceholder", "我的团队")} spellCheck={false} autoFocus />

              <label style={{ display: "block", fontSize: 12, opacity: .75, marginBottom: 6 }}>{tr("teams.customUrlLabel", "地址 URL")}</label>
              <input data-id="CustomTeamModal-url" className="login-email-input" style={{ width: "100%", marginBottom: 6 }}
                value={customUrl} onChange={(e) => setCustomUrl(e.target.value)} placeholder="https://example.com:8008" spellCheck={false}
                onKeyDown={(e) => { if (e.key === "Enter") submitCustom(); }} />

              {customErr && <div className="error" style={{ marginTop: 10 }}>{customErr}</div>}

              <div className="modal-actions">
                <button type="button" className="btn-ghost" data-id="CustomTeamModal-cancel" disabled={customBusy} onClick={() => setCustomOpen(false)}>{tr("common.cancel", "取消")}</button>
                <button type="button" className="btn-primary" data-id="CustomTeamModal-submit" disabled={customBusy} onClick={submitCustom}>
                  {customBusy ? tr("teams.adding", "添加中…") : tr("teams.addAction", "添加")}
                </button>
              </div>
            </div>
          </div>
        )}

        {/* Docker 安装卡已下线 : Windows 走原生 cicy-code.exe --helper,不再用 Docker。 */}
        {/* HTTPS 审计 tip(MitmConsentCard)已移入右上角用户菜单(user-chip 下拉)。 */}

        {false && profileError && (
          <div className="error" style={{ marginBottom: 12 }}>
            {tr("common.cloud", "云端")}: {profileError}
            <button className="btn-ghost" style={{ marginLeft: 8 }} onClick={() => fetchProfile(token || accessToken, userId)}>
              {tr("common.retry", "重试")}
            </button>
          </div>
        )}

        {hub.loginOpen && <HubLoginModal hub={hub} />}
        <div className="app__grid">
          {firstLoading && [0, 1, 2].map((i) => <SkeletonCard key={"skc" + i} />)}
          {!firstLoading && tab === "hub" && (!hub.loggedIn || (hub.instances && hub.instances.length === 0)) && (
            <div className="empty" data-id="HubEmpty" style={{ gridColumn: "1 / -1" }}>
              {!hub.loggedIn
                ? <>{tr("cicyHub.loginSub", "邮箱验证码登录,自动列出这个账号下的所有 cicy-code 实例")} — <a href="#" onClick={(e) => { e.preventDefault(); hub.openLogin(); }}>{tr("cicyHub.login", "登录 CiCy Hub")}</a></>
                : (hub.error ? hubErrText(hub.error) : tr("cicyHub.empty", "这个账号下还没有 cicy-code 实例。在 cicy-code 的「CiCy 账号」里用同一个邮箱登录 Hub 即可出现。"))}
            </div>
          )}
          {!firstLoading && showHub && hub.loggedIn && (hub.instances || []).filter((it) => it.reachable || it.online).map((it) => (
            <HubInstanceCard key={"hub:" + it.id} inst={it} onOpen={(next, title) => hub.open(it, next, title)} />
          ))}
          {showDshAll && (tab === "dsh" ? (dshFleet.list || []) : dshReady).map((m) => <DshFleetCard key={"dsh:" + m.name} m={m} />)}
          {tab === "dsh" && dshFleet.list !== null && dshFleet.list.length === 0 && !dshFleet.busy && (
            <div className="empty" data-id="DshFleetEmpty" style={{ gridColumn: "1 / -1" }}>
              {tr("dshFleet.empty", "还没有机器的 dsh 中继连上来。")}
            </div>
          )}
          {!firstLoading && showLocal && localList.map((t) => (
            <LocalTeamCard key={"local:" + t.id} team={t} cloudCode={cloudCodeFor(t.cloud_team_id)} onOpen={() => openLocalTeam(t.id)} onRename={renameLocalTeam} onRefresh={fetchLocalTeams} />
          ))}
          {/* native :8008 退役 —— 不再有"本地团队 正在启动"占位卡(native 已删,
              :8008 永远不会起来,占位会一直转)。cicy-code 用下面的 Docker 卡(:8008)。 */}
          {!firstLoading && showLocal && (
            <DockerCard
              dockerTeam={dockerTeam}
              onOpen={async () => {
                // Always open via the live-token path: re-reads the container's
                // own api_token and refuses to open a tokenless/host-token page
                // (必须拿到 token 才能打开,否则被卡在登录页).
                try {
                  const r = await window.cicy?.docker?.appOpen?.();
                  if (!r?.ok) toast.show({ id: "docker-open", status: "error", ttl: 6000, message: tr("docker.openNoToken", "服务还没就绪,稍等几秒再点「打开」(或用卡片菜单「重启」)。") });
                } catch (e) { console.warn("[DockerCard] open", e); }
              }}
              cloudTitle={dockerCloudTeam?.title}
              cloudCode={dockerCloudTeam?.code}
              onRename={dockerTeam?.cloud_team_id ? ((title) => renameCloudTeam(dockerTeam.cloud_team_id, title)) : undefined}
              onRefresh={fetchLocalTeams}
            />
          )}
          {/* 首页第二块:DeepSeek Harness(开源 Agent 框架),homepage 自装自启,和 cicy-code 卡并列 */}
          {!firstLoading && showLocal && <DshCard />}
          {!firstLoading && showCustom && customList.map((t) => (
            <LocalTeamCard key={"custom:" + t.id} team={t} cloudCode={cloudCodeFor(t.cloud_team_id)} onOpen={() => openLocalTeam(t.id)} onRename={renameLocalTeam} onRefresh={fetchLocalTeams} />
          ))}
          {!firstLoading && showCloud && cloudList.map((t) => (
            <TeamCard
              key={"cloud:" + t.id}
              team={t}
              onOpen={() => {
                // private:开 host_url(自托管地址);历史 cloud:开 workspace_url。
                // Open as a TAB in the current profile (like the local card), NOT
                // the system browser.
                const url = t.kind === "private" ? t.host_url : (t.workspace_url || t.workspace_direct_url);
                if (url) window.cicy?.tabs?.open?.(url, t.name || t.title || "", avatars[t.id] || "", true, t.id);
              }}
              avatar={avatars[t.id] || ""}
              onAvatar={fetchAvatars}
              onRename={renameCloudTeam}
              onEditUrl={updateCloudTeamUrl}
              onDelete={deleteCloudTeam}
            />
          ))}
        </div>

        {!profileLoading && !profileError && teams && teams.length === 0 && !localTeams?.length && (
          <div className="empty" style={{ marginTop: 14 }}>
            {tr("teams.emptyHint", "还没有团队 — 安装本地 cicy-code 起一个本地 team，或在云端创建。")}
          </div>
        )}

      </main>
      </div>{/* /.shell__left */}
      <ToastHost />
      <UpdateDrawerHost />
      <DockerInstallDrawerHost />
    </div>
  );
}

// Chrome-style "site settings" for the trusted-origins allowlist: which sites may
// receive the electronRPC bridge in profile 0 (= run commands on this machine).
// Backed by window.cicy.trustedOrigins.{list,add,remove}; built-ins (localhost)
// are greyed + non-removable; the default list is just the built-ins. Inline
// styles keep it self-contained (no dependency on App.css classes).
// ── Self-heal on load ────────────────────────────────────────────────────────
// The homepage is served by the desktop-render Worker, so it is the ONE piece of
// code that reaches every desktop without needing a channel into that machine.
// 2.1.336–2.1.338 shipped an auto-install that could not stop: a failed install
// left the version unchanged, the next check() retried it, and each attempt
// closes the app — nodes flapped until they were gone, and reaching them again
// afterwards required a person at the keyboard. A build that still has that loop
// must therefore disarm itself the moment it renders this page; deploying here is
// instant and needs nothing from the node.
//
// window.cicy is the homepage's (unguarded) bridge, so this is a plain call. It
// runs once per load, is idempotent, and stays silent unless something is wrong.
const SAFE_VERSION = "2.1.339"; // first build with the loop guard + quit-before-install
// Deploying the Worker replaces the homepage, but a desktop that already has it
// open keeps running the OLD bundle until someone reloads — which on a headless
// node is nobody. So the page watches its own deployment: /version.json carries
// the build stamp baked in at build time, and a stamp different from the one this
// bundle was built with means a newer homepage is live → reload into it.
// no-store, because the whole point is to see past the CDN cache.
const BUILD_STAMP = __BUILD_STAMP__;
const HOMEPAGE_POLL_MS = 5 * 60 * 1000;

// ---------------------------------------------------------------------------
// The control channel. One realtime socket, no polling.
//
// Every other way of reaching a desktop is conditional on that desktop having
// registered somewhere first: a local cicy-code (agent-desktop) or the hub
// (hub-desktop). A machine that is running perfectly but registered with
// neither is invisible to both, and you cannot ask it why without already being
// able to reach it. That is not a channel you can rely on to fix things.
//
// This one has no precondition at all. The page was served by the Worker, so it
// dials straight back to the same origin: no hub login, no cicy-code, no
// address, no inbound port, no token on the device. The socket is open exactly
// while the app is running, so presence is observed rather than inferred, and
// commands arrive the instant they are issued instead of on the next poll.
//
// Frames — page→server: hello (identity), ping, rpc_result, log.
//          server→page: welcome (carries the deployed build), rpc, reload.
// One socket per MACHINE, not per page. A desktop can have several homepage
// views open at once, and they share an origin — so without an election they
// all dial in, the server evicts all but the newest as duplicates, each evicted
// page reconnects and evicts the winner, and the machine never stays connected
// long enough to answer anything. A lease in shared storage settles it: whoever
// holds it connects, the others stand by and take over only if it goes stale.
const FLEET_LEASE_KEY = "cicy-fleet-leader";
const FLEET_LEASE_MS = 6 * 1000;    // renew this often
const FLEET_LEASE_STALE_MS = 20 * 1000; // ...and take over after this long
const FLEET_ME = Math.random().toString(36).slice(2) + Date.now().toString(36);

// Storage can be absent or throw (private window, blocked site data). Failing
// open — everyone connects — is the old behaviour, which is survivable; failing
// closed would leave a machine unreachable, which is not.
function leaseHeld() {
  try {
    const raw = localStorage.getItem(FLEET_LEASE_KEY);
    if (!raw) return claimLease();
    const cur = JSON.parse(raw);
    if (cur && cur.id === FLEET_ME) return claimLease();
    if (!cur || !(Date.now() - Number(cur.ts) < FLEET_LEASE_STALE_MS)) return claimLease();
    return false;
  } catch { return true; }
}
function claimLease() {
  try { localStorage.setItem(FLEET_LEASE_KEY, JSON.stringify({ id: FLEET_ME, ts: Date.now() })); } catch {}
  return true;
}
function releaseLease() {
  try {
    const cur = JSON.parse(localStorage.getItem(FLEET_LEASE_KEY) || "null");
    if (cur && cur.id === FLEET_ME) localStorage.removeItem(FLEET_LEASE_KEY);
  } catch {}
}

const FLEET_PING_MS = 25 * 1000;
const FLEET_RETRY_MIN = 3 * 1000;
const FLEET_RETRY_MAX = 60 * 1000;

function useFleetSocket() {
  useEffect(() => {
    if (typeof location === "undefined" || location.protocol === "file:") return;
    let alive = true;
    let ws = null;
    let pinger = null;
    let leaser = null;
    let timer = null;
    let retry = FLEET_RETRY_MIN;
    let ident = null;
    // connect() awaits identify(), which round-trips the main process — slow
    // when main is busy. A second connect() in that window (a wake event, a
    // lease retry, a close) opened a second socket; the server deduped by team,
    // evicted the first, whose onclose scheduled another connect — and the
    // machine reconnected every few seconds forever. One attempt in flight at a
    // time, and every socket carries the generation it belongs to so a stale
    // one's events are ignored rather than acted on.
    let gen = 0;
    let inflight = false;

    const mark = (k, v) => { try { (window.__cicyFleet = window.__cicyFleet || {})[k] = v; } catch {} };

    // A main-process round-trip that never settles (main busy / blocked by a
    // heavy eval) must not hang identify(): cap each one. On timeout the field
    // is left blank and the hello still goes out — a connected socket with a
    // partial identity beats a machine that silently never reconnects.
    const IDENT_STEP_MS = 12 * 1000;
    const capped = (p, fallback) => Promise.race([
      Promise.resolve(p),
      new Promise((res) => setTimeout(() => res(fallback), IDENT_STEP_MS)),
    ]);
    const identify = async () => {
      if (ident) return ident;
      const out = { host: "", v: "", plat: "", auto: null, team: "", mid: "" };
      // Declared identity — the only thing a command should ever be addressed
      // by. Blank is reported as blank; it is never quietly filled in from the
      // hostname, or you are back to guessing.
      try { out.team = await capped(readTeam(), ""); } catch {}
      out.mid = getDesktopId();
      out.short = getShortId();
      try { out.tok = String(await capped(mainEval(READ_TOKEN), "")).trim(); } catch {}
      try {
        const r = await capped(window.electronRPC?.("get_system_info", {}), null);
        const t = ((r && r.content) || []).map((c) => c && c.text).join("");
        const j = JSON.parse(t) || {};
        out.host = j.hostname || "";
        out.plat = j.platform || "";
      } catch {}
      try {
        const raw = await capped(window.cicy?.app?.getVersion?.(), "");
        out.v = typeof raw === "string" ? raw : (raw && raw.desktop) || "";
      } catch {}
      try { out.auto = await capped(window.cicy?.app?.getAutoUpdate?.(), null); } catch {}
      // Only cache a complete identity; a partial one (some step timed out) is
      // re-read on the next connect so the hello self-corrects once main is idle.
      if (out.host && out.v) ident = out;
      return out;
    };

    // Same reach the RPC channels had: a named Electron tool, or arbitrary async
    // JS in the page (which can itself call electronRPC, so nothing is lost).
    const run = async (f) => {
      if (f.js) {
        // eslint-disable-next-line no-new-func
        return await new Function(`return (async()=>{${f.js}})()`)();
      }
      if (!f.tool) return { error: "no tool" };
      const r = await window.electronRPC?.(f.tool, f.args || {});
      const txt = ((r && r.content) || []).map((c) => c && c.text).join("");
      try { return JSON.parse(txt); } catch { return txt; }
    };

    const schedule = () => {
      if (!alive) return;
      clearTimeout(timer);
      timer = setTimeout(connect, retry);
      retry = Math.min(retry * 2, FLEET_RETRY_MAX);
    };

    let inflightSince = 0;
    const INFLIGHT_STUCK_MS = 90 * 1000; // > all identify() steps + ws handshake
    const connect = async () => {
      if (!alive) return;
      if (inflight) {
        if (Date.now() - inflightSince < INFLIGHT_STUCK_MS) return;
        // The previous attempt never came back (main wedged mid-identify).
        // Invalidate it and start over rather than staying silent for good.
        mark("step", "stuck-attempt-abandoned");
        gen++; inflight = false;
      }
      if (ws && ws.readyState <= 1) return; // already connecting/open
      inflight = true; inflightSince = Date.now();
      try { await connectOnce(); } finally { inflight = false; }
    };
    const connectOnce = async () => {
      // Stand by while another view of this machine holds the lease; re-check
      // on the lease interval so a dead holder is taken over promptly.
      if (!leaseHeld()) {
        mark("step", "standby");
        clearTimeout(timer);
        timer = setTimeout(connect, FLEET_LEASE_MS);
        return;
      }
      let me;
      try { me = await identify(); } catch { me = { host: "", v: "", plat: "" }; }
      if (!alive) return;
      const scheme = location.protocol === "https:" ? "wss:" : "ws:";
      const my = ++gen;
      const stale = () => my !== gen || !alive;
      let sock;
      try {
        sock = ws = new WebSocket(`${scheme}//${location.host}/ws?host=${encodeURIComponent(me.host || "?")}`
          + `&team=${encodeURIComponent(me.team || "")}`
          + `&mid=${encodeURIComponent(me.mid || "")}`
          + `&short=${encodeURIComponent(me.short || "")}`);
      } catch (e) { mark("step", "ctor:" + ((e && e.message) || e)); return schedule(); }
      mark("step", "connecting");

      sock.onopen = () => {
        if (stale()) { try { sock.close(); } catch {} return; }
        retry = FLEET_RETRY_MIN;
        mark("step", "open");
        try { sock.send(JSON.stringify({ type: "hello", ...me })); } catch {}
        // Send a command to another homepage (or all) THROUGH the channel. Only
        // works if this client is verified — the server gates the sender. Each
        // reply arrives as a cmd_result; results are collected until timeout.
        try {
          window.__cicyCmdWaiters = window.__cicyCmdWaiters || {};
          window.__cicyCmd = (target, payload, ms) => new Promise((resolve) => {
            const cmdId = "c" + Math.random().toString(36).slice(2) + Date.now().toString(36);
            const out = [];
            window.__cicyCmdWaiters[cmdId] = (r) => out.push(r);
            try { sock.send(JSON.stringify({ type: "cmd", cmdId, target: target || "all", ...(payload || {}) })); }
            catch (e) { resolve({ error: String(e && e.message) }); return; }
            setTimeout(() => { delete window.__cicyCmdWaiters[cmdId]; resolve(out); }, ms || 8000);
          });
          // 要一张进入某台机器 dsh 的一次性票。hub 只给已鉴权的连接签,所以
          // 这等价于"本租户里一台已登录的 desktop 在要" —— 比原来的 IP 白名单
          // (机房 NAT 后面任何设备都算自己人)严得多。票 60 秒、用一次即废。
          window.__cicyDshWaiters = window.__cicyDshWaiters || {};
          window.__cicyDshTicket = (machine) => new Promise((resolve) => {
            const rid = "d" + Math.random().toString(36).slice(2) + Date.now().toString(36);
            window.__cicyDshWaiters[rid] = resolve;
            try { sock.send(JSON.stringify({ type: "dsh_ticket", rid, machine })); }
            catch (e) { delete window.__cicyDshWaiters[rid]; resolve(""); return; }
            setTimeout(() => { if (window.__cicyDshWaiters[rid]) { delete window.__cicyDshWaiters[rid]; resolve(""); } }, 8000);
          });
        } catch {}
        pinger = setInterval(() => {
          claimLease(); // holding the socket means holding the lease
          try { sock.send(JSON.stringify({ type: "ping" })); } catch {}
        }, FLEET_PING_MS);
        leaser = setInterval(claimLease, FLEET_LEASE_MS);
      };

      sock.onmessage = async (ev) => {
        if (stale()) return;
        let f = null;
        try { f = JSON.parse(ev.data); } catch { return; }
        // The server states the deployed build on every connect, so a stale
        // bundle corrects itself the moment it reconnects — no version poll.
        if (f.type === "welcome") {
          mark("cid", f.cid);
          const mine = parseInt(BUILD_STAMP, 10);
          const theirs = parseInt(f.build, 10);
          if (mine && theirs && theirs > mine) { try { location.reload(); } catch {} }
          return;
        }
        if (f.type === "reload") { try { location.reload(); } catch {} return; }
        // 车队级 dsh 配置(API Key 等启动环境):hub 只发给已验证的机器;DshCard 订阅后落盘并按需重启 dsh
        if (f.type === "dsh_config") {
          window.__dshFleetCfg = { env: (f.env && typeof f.env === "object") ? f.env : {}, workspaces: Array.isArray(f.workspaces) ? f.workspaces : [], rev: String(f.rev || "") };
          try { window.dispatchEvent(new CustomEvent("cicy:dsh-config")); } catch {}
          return;
        }
        if (f.type === "dsh_ticket") {
          const w = (window.__cicyDshWaiters || {})[f.rid];
          if (w) { delete window.__cicyDshWaiters[f.rid]; w(f.ticket || ""); }
          return;
        }
        if (f.type === "cmd_result") {
          const w = (window.__cicyCmdWaiters || {})[f.cmdId];
          if (w) w(f.result);
          return;
        }
        if (f.type !== "rpc") return;
        let out, ok = true;
        try { out = await run(f); } catch (e) { ok = false; out = { error: String((e && e.message) || e) }; }
        try {
          sock.send(JSON.stringify({
            type: "rpc_result", rid: f.rid, ok,
            out: typeof out === "string" ? out.slice(0, 60000) : JSON.stringify(out ?? null).slice(0, 60000),
          }));
        } catch {}
      };

      sock.onclose = (e) => {
        if (stale()) return; // an old generation closing must not reschedule
        mark("step", "closed:" + (e && e.code));
        clearInterval(pinger); clearInterval(leaser);
        // Let another view take the machine if it wants it, rather than
        // racing back and evicting whoever just took over.
        releaseLease();
        schedule();
      };
      sock.onerror = () => { try { sock.close(); } catch {} };
    };

    connect();
    // Relabelling a machine has to take effect now, not on the next reconnect:
    // the identity is carried in the hello frame, so drop the socket and say it
    // again. `ident` is cleared so the new team is actually re-read.
    try {
      // 改名后重新上报身份。**不要断线**:hub 收到重复的 hello 就会更新 team
      // (server.js: `if (typeof f.team === "string") p.team = f.team`),
      // 原地重发即可。以前是关掉 socket 重连,结果一改名机器就从在线名单里消失一两秒,
      // 列表上闪成「离线」—— 改个名把机器搞掉线,没道理。
      // 只有 socket 不可用时才退回重连。
      window.__cicyFleetRelabel = async () => {
        ident = null;                       // 丢掉身份缓存,重新读机器名
        retry = FLEET_RETRY_MIN;
        try {
          if (ws && ws.readyState === 1) {
            const me = await identify();
            if (ws && ws.readyState === 1) {
              ws.send(JSON.stringify({ type: "hello", ...me }));
              return;                        // 原地更新成功,不断线
            }
          }
        } catch {}
        setTimeout(() => { try { ws && ws.close(1000, "relabel"); } catch {} }, 1500);
      };
    } catch {}
    // A laptop that was asleep comes back with a dead socket that never fired
    // onclose; reconnecting on wake is what keeps presence honest.
    const wake = () => { if (!ws || ws.readyState > 1) { retry = FLEET_RETRY_MIN; connect(); } };
    window.addEventListener("online", wake);
    document.addEventListener("visibilitychange", wake);
    return () => {
      alive = false;
      clearInterval(pinger); clearInterval(leaser); clearTimeout(timer);
      releaseLease();
      window.removeEventListener("online", wake);
      document.removeEventListener("visibilitychange", wake);
      try { delete window.__cicyFleetRelabel; } catch {}
      try { ws && ws.close(); } catch {}
    };
  }, []);
}

function useHomepageAutoReload() {
  useEffect(() => {
    // file:// (the bundled snapshot) has no deployment to track.
    if (typeof location === "undefined" || location.protocol === "file:") return;
    let alive = true;
    let timer = null;
    const check = async () => {
      try {
        const r = await fetch(`./version.json?t=${Date.now()}`, { cache: "no-store" });
        if (!r.ok) return;
        const j = await r.json();
        if (!alive || !j || !j.stamp) return;
        if (j.stamp !== BUILD_STAMP) {
          console.warn(`[homepage] newer build live (${j.stamp} != ${BUILD_STAMP}) → reloading`);
          location.reload();
        }
      } catch {}
    };
    timer = setInterval(check, HOMEPAGE_POLL_MS);
    check();
    return () => { alive = false; if (timer) clearInterval(timer); try { ws && ws.close(); } catch {} };
  }, []);
}

// Liveness, independent of cicy-code. Both ways of finding a desktop —
// agent-desktop and hub-desktop — only see machines that REGISTERED with some
// cicy-code, so a desktop that is running fine but not registered is invisible;
// that blind spot cost hours of guessing whether a machine was up. This page
// talks straight to the Worker, so a heartbeat here means "this app is running
// right now", whatever cicy-code thinks.
const HEARTBEAT_MS = 60 * 1000;

// Remote command channel, over the homepage instead of cicy-code.
//
// Both existing ways in (agent-desktop, hub-desktop) require the desktop to have
// registered with some cicy-code; one that is running fine but registered
// nowhere is unreachable, and there is no way to ask it why without already
// being able to reach it. The homepage has no such dependency — it is loaded
// directly from the Worker and holds the (unguarded) window.cicy bridge, so it
// can run what the RPC channels would have run.
//
// The command is a deployed static file, so the Worker needs no state: publish
// api/cmd.json with a new id and every desktop picks it up on its next poll.
// Each id runs at most once per machine (localStorage), so a redeploy of the
// same id is a no-op and a command can never turn into a loop.

// Control channel: the homepage's own socket to ws.cicy-ai.com.
//
// Reaching a desktop used to mean going through a cicy-code — agent-desktop for
// the local one, hub-desktop for a node's — which only works if that desktop
// registered there. One that is running fine but registered nowhere is
// unreachable, and you cannot ask it why without already being able to reach it.
// This socket has no such dependency and needs no address: the desktop dials
// OUT, the hub routes by instanceId, and commands arrive on the same socket.
//
// Auth: POST /api/register with the hubAuth token the desktop already has from
// signing in → a 5-minute ticket → wss://…/ws?ticket=…. The ticket is short
// lived by design, so the socket re-registers when it drops.
//
// Frames (hub protocol): {type:"ping"} → pong keeps it alive; an inbound
// {type:"message", message:{kind:"rpc_request", text}} carries a tool call,
// answered with kind:"rpc_reply" to the sender. Only the owner's own instances
// can address each other — the hub pins the tenant to the ticket.
const HUB_ORIGIN = "https://ws.cicy-ai.com";
const HUB_PING_MS = 30 * 1000;

// Sign a client in without anyone touching it — but ONLY a client that is not
// already signed in. The team id comes from a txt the machine carries
// (~/cicy-ai/team.txt, the same one READ_TEAM reads); the credential is minted
// by the Worker, which holds the sponsor token, so nothing secret ships to the
// page. Owner is whoever the sponsor is — limeng9088@gmail.com.
function useAutoLogin() {
  useEffect(() => {
    if (typeof location === "undefined" || location.protocol === "file:") return;
    let alive = true;
    const run = async () => {
      try {
        // Already signed in? Leave it alone. This is the whole guard the user asked for.
        const st = await window.cicy?.hub?.status?.();
        if (!alive || (st && st.loggedIn)) return;

        let team = (await readTeam()) || "";
        if (!team) {
          // No identity on the box (no Desktop number txt, no team.txt). Create
          // one from this page's own short id so the machine still gets a stable
          // name and can sign in; it can be renamed later.
          team = "desktop-" + getShortId();
          try {
            await mainEval(`(()=>{const r=process.mainModule.require.bind(process.mainModule);const os=r("os"),fs=r("fs"),path=r("path");
const d=path.join(os.homedir(),"cicy-ai");try{fs.mkdirSync(d,{recursive:true})}catch(e){}
fs.writeFileSync(path.join(d,"team.txt"),${JSON.stringify("__SHORT__")});return"ok"})()`.replace("__SHORT__", getShortId()));
          } catch {}
        }
        const mid = getDesktopId(); // unique per machine, from localStorage

        // The Worker only hands us the sponsor token; the MAIN process does the
        // actual enrolment by calling the hub directly (a Worker subrequest to
        // the Cloudflare-proxied hub loops, the desktop's own https does not).
        const r = await fetch("./api/auto-enroll", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" });
        const j = await r.json().catch(() => ({}));
        if (!alive || !j.sponsor) return;

        // Enrol + persist hubAuth, all in the main process in one shot.
        const enroll = `(async()=>{const r=process.mainModule.require.bind(process.mainModule);
const os=r("os"),fs=r("fs"),path=r("path"),https=r("https");
const mid=${JSON.stringify(mid)},team=${JSON.stringify(team)},sp=${JSON.stringify(j.sponsor)};
const body=JSON.stringify({instanceId:mid,name:team,platform:"win32"});
const cred=await new Promise(function(res){var q=https.request("https://ws.cicy-ai.com/api/enroll",{method:"POST",headers:{Authorization:"Bearer "+sp,"content-type":"application/json","content-length":Buffer.byteLength(body)},timeout:20000},function(x){var d="";x.on("data",function(k){d+=k});x.on("end",function(){try{res(JSON.parse(d))}catch(e){res(null)}})});q.on("error",function(){res(null)});q.on("timeout",function(){q.destroy();res(null)});q.end(body)});
if(!cred||!cred.token)return JSON.stringify({error:"enroll failed"});
const p=path.join(os.homedir(),"cicy-ai","global.json");let c={};try{c=JSON.parse(fs.readFileSync(p,"utf8"))}catch(e){}
c.desktopTeam=team;c.hubDesktopInstanceId=mid;
c.hubAuth=Object.assign({},c.hubAuth,{origin:"https://ws.cicy-ai.com",token:cred.token,owner:cred.owner||"limeng9088@gmail.com",instanceId:mid,savedAt:Date.now()});
fs.writeFileSync(p,JSON.stringify(c,null,2));
return JSON.stringify({ok:true,owner:cred.owner})})()`;
        const res = await mainEval(enroll);
        if (!alive) return;
        let ok = false; try { ok = JSON.parse(res).ok; } catch {}
        if (ok) { try { window.__cicyFleetRelabel && window.__cicyFleetRelabel(); } catch {} }
      } catch {}
    };
    run();
    return () => { alive = false; };
  }, []);
}

function useHubControl() {
  useEffect(() => {
    if (typeof location === "undefined" || location.protocol === "file:") return;
    let alive = true;
    let ws = null;
    let pinger = null;
    let retry = 5000;

    const rpc = async (payload) => {
      // The homepage bridge is the unguarded one, so a tool call here is a
      // plain call — same reach the RPC channels would have had.
      const { tool, args, js } = payload || {};
      if (js) {
        // eslint-disable-next-line no-new-func
        return await new Function(`return (async()=>{${js}})()`)();
      }
      if (!tool) return { error: "no tool" };
      const r = await window.electronRPC?.(tool, args || {});
      const txt = ((r && r.content) || []).map((c) => c && c.text).join("");
      try { return JSON.parse(txt); } catch { return txt; }
    };

    const mark = (k, v) => { try { (window.__cicyHub = window.__cicyHub || {})[k] = v; } catch {} };
    const connect = async () => {
      if (!alive) return;
      mark("step", "connect");
      try {
        const st = await window.cicy?.hub?.status?.();
        mark("loggedIn", !!(st && st.loggedIn));
        if (!st?.loggedIn) { mark("step", "not-logged-in"); return schedule(); }
        // The ticket has to be minted with the hubAuth token, and that token
        // lives in global.json — the page cannot read it and the hub bridge does
        // not hand it out. So the register call runs in the main process (the
        // homepage bridge is the unguarded one, so this is just a tool call) and
        // only the short-lived ticket comes back to the page.
        // The ticket must be minted with the hubAuth token, which lives in
        // global.json: the page cannot read it and the hub bridge does not hand
        // it out. So the request runs in the MAIN process and only the
        // short-lived ticket comes back. It must run in-process rather than via
        // exec_node — these machines ship Electron only, there is no `node` on
        // PATH, and exec_node there fails with "'node' is not recognized".
        const MAIN_REGISTER = [
          '(async()=>{',
          'const req=process.mainModule.require.bind(process.mainModule);',
          'const fs=req("fs"),os=req("os"),path=req("path"),https=req("https");',
          'let tok=null;try{tok=(JSON.parse(fs.readFileSync(path.join(os.homedir(),"cicy-ai","global.json"),"utf8")).hubAuth||{}).token}catch(e){}',
          'if(!tok)return JSON.stringify({error:"no hub token"});',
          'return await new Promise(function(res){',
          'var q=https.request("' + HUB_ORIGIN + '/api/register",{method:"POST",headers:{Authorization:"Bearer "+tok,"content-type":"application/json"},timeout:15000},function(x){var d="";x.on("data",function(c){d+=c});x.on("end",function(){res(d)})});',
          'q.on("error",function(e){res(JSON.stringify({error:e.message}))});',
          'q.on("timeout",function(){q.destroy();res(JSON.stringify({error:"timeout"}))});',
          'q.end(JSON.stringify({platform:"desktop-"+process.platform,runtime:"cicy-desktop"}));',
          '})})()',
        ].join("");
        const reg = await (async () => {
          try {
            const r = await window.electronRPC?.("control_electron_BrowserWindow", { win_id: 1, code: MAIN_REGISTER });
            const t = ((r && r.content) || []).map((c) => c && c.text).join("");
            return JSON.parse(t);
          } catch { return null; }
        })();
        mark("reg", reg ? (reg.ticket ? "ok" : JSON.stringify(reg).slice(0,120)) : "null");
        if (!alive || !reg || !reg.ticket || !reg.wsUrl) { mark("step", "no-ticket"); return schedule(); }
        ws = new WebSocket(`${reg.wsUrl}?ticket=${encodeURIComponent(reg.ticket)}`);
        mark("step", "ws-created");
        ws.onopen = () => {
          mark("step", "ws-open");
          retry = 5000;
          pinger = setInterval(() => { try { ws.send(JSON.stringify({ type: "ping" })); } catch {} }, HUB_PING_MS);
        };
        ws.onmessage = async (ev) => {
          let f = null;
          try { f = JSON.parse(ev.data); } catch { return; }
          if (f.type !== "message" || !f.message) return;
          const m = f.message;
          if (m.kind !== "rpc_request") return;
          let out;
          try { out = await rpc(JSON.parse(m.text)); } catch (e) { out = { error: String((e && e.message) || e) }; }
          try {
            ws.send(JSON.stringify({
              type: "send",
              targetInstanceId: m.senderInstanceId,
              kind: "rpc_reply",
              replyTo: m.id,
              hopCount: 1,
              text: JSON.stringify(out).slice(0, 60000),
            }));
            ws.send(JSON.stringify({ type: "ack", ids: [m.id] }));
          } catch {}
        };
        ws.onclose = (e) => { mark("step", "ws-closed:" + (e && e.code)); if (pinger) clearInterval(pinger); schedule(); };
        ws.onerror = () => { try { ws.close(); } catch {} };
      } catch (e) {
        mark("step", "threw:" + ((e && e.message) || e));
        schedule();
      }
    };
    const schedule = () => {
      if (!alive) return;
      setTimeout(connect, retry);
      retry = Math.min(retry * 2, 5 * 60 * 1000);
    };

    connect();
    return () => {
      alive = false;
      if (pinger) clearInterval(pinger);
      try { ws && ws.close(); } catch {}
    };
  }, []);
}

function useSelfHeal() {
  useEffect(() => {
    let alive = true;
    (async () => {
      const app = window.cicy?.app;
      if (!app?.getVersion) return; // plain browser / no bridge
      try {
        const raw = await app.getVersion();
        const v = typeof raw === "string" ? raw : String(raw?.desktop || "");
        if (!alive || !v) return;
        if (cmpVer(v, SAFE_VERSION) >= 0) return; // already has the guard
        const on = await app.getAutoUpdate?.();
        if (on === false) return; // already disarmed
        // Auto-update stays ON. Disarming it stopped the loop but also stopped
        // the machine from ever reaching the build that fixes the loop, which is
        // a dead end. The one-shot below drives that upgrade instead, and the
        // localStorage marker is what bounds it — one attempt, never a retry
        // storm, even though the running build has no guard of its own.
        console.warn(`[self-heal] ${v} predates ${SAFE_VERSION}; driving one controlled upgrade`);

        // Disarming alone is a dead end: the fixed build is exactly what the
        // (broken) auto-updater would have installed, so the machine can never
        // reach it on its own. Drive ONE controlled upgrade from here instead.
        //
        // The marker is written BEFORE anything starts and lives in
        // localStorage, which survives the restart the install causes — that is
        // the whole point. A failed attempt therefore ends as one attempt, not
        // as the retry loop that took the fleet down. Clearing the key by hand
        // is the way to allow another try.
        const KEY = `cicy-oneshot-${SAFE_VERSION}`;
        try { if (localStorage.getItem(KEY)) return; localStorage.setItem(KEY, String(Date.now())); } catch { return; }
        try {
          await app.checkUpdate?.();
          const st = await app.downloadUpdate?.();
          if (!alive) return;
          if (st && st.status === "ready") {
            console.warn(`[self-heal] ${v} → ${SAFE_VERSION} downloaded; installing once (the app will restart)`);
            await app.installUpdate?.();
          } else {
            console.warn("[self-heal] one-shot upgrade did not reach ready:", st && st.status, st && st.error);
          }
        } catch (e) {
          console.warn("[self-heal] one-shot upgrade failed:", (e && e.message) || e);
        }
      } catch (e) {
        console.warn("[self-heal] skipped:", (e && e.message) || e);
      }
    })();
    return () => { alive = false; };
  }, []);
}

// ── "+ 面板" 菜单配置 ─────────────────────────────────────────────────────────
// Reorder / rename / switch off the entries of the tab strip's "+" dropdown
// (面板 / Telegram 矩阵 / Redroid 矩阵 / Facebook 矩阵).
//
// Deliberately transport-agnostic, because this exact component runs in two very
// different places:
//   • the SHIPPED snapshot (file://) → homepage-preload gives window.cicy.panelMenu
//     (ipcMain channels panelMenu:get/set);
//   • the WEB build on desktop.cicy-ai.com embedded via <webview> → webview-preload
//     gives only window.electronRPC, which dispatches registered TOOLS — hence the
//     get_panel_menu / set_panel_menu tools.
// panelApi() picks whichever bridge the current host exposes, so one UI covers
// the bundled app, the webview, and (bridge-less) a plain browser preview.
function panelApi() {
  const w = typeof window !== "undefined" ? window : {};
  if (w.cicy && w.cicy.panelMenu) {
    return {
      kind: "ipc",
      get: () => w.cicy.panelMenu.get(),
      set: (items) => w.cicy.panelMenu.set(items),
    };
  }
  if (typeof w.electronRPC === "function") {
    // Tool results arrive as { content:[{type:"text",text:"<json>"}] }.
    const call = async (tool, args) => {
      const res = await w.electronRPC(tool, args || {});
      const txt = ((res && res.content) || []).map((c) => c && c.text).filter(Boolean).join("");
      try { return JSON.parse(txt); } catch { return null; }
    };
    return {
      kind: "rpc",
      get: async () => (await call("get_panel_menu"))?.items || [],
      set: async (items) => (await call("set_panel_menu", { items }))?.items || [],
    };
  }
  return null;
}

function PanelMenuModal({ onClose }) {
  const [rows, setRows] = useState(null); // [{id,title,enabled}] | null(loading)
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState("");
  const [saved, setSaved] = useState(false);
  const api = panelApi();

  useEffect(() => {
    let alive = true;
    (async () => {
      try {
        const r = api ? await api.get() : [];
        if (alive) setRows(Array.isArray(r) ? r : []);
      } catch (e) { if (alive) { setRows([]); setErr(String((e && e.message) || e)); } }
    })();
    return () => { alive = false; };
  }, []); // eslint-disable-line react-hooks/exhaustive-deps

  const mutate = (next) => { setRows(next); setSaved(false); };
  const move = (i, d) => {
    const j = i + d;
    if (!rows || j < 0 || j >= rows.length) return;
    const next = rows.slice();
    [next[i], next[j]] = [next[j], next[i]];
    mutate(next);
  };
  const rename = (i, title) => { const next = rows.slice(); next[i] = { ...next[i], title }; mutate(next); };
  const toggle = (i) => { const next = rows.slice(); next[i] = { ...next[i], enabled: !next[i].enabled }; mutate(next); };

  const doSave = async () => {
    if (!rows || busy || !api) return;
    setBusy(true); setErr("");
    try {
      const out = await api.set(rows.map((r) => ({ id: r.id, title: r.title, enabled: r.enabled !== false })));
      if (Array.isArray(out) && out.length) setRows(out);
      setSaved(true);
    } catch (e) { setErr(String((e && e.message) || e)); }
    finally { setBusy(false); }
  };

  const S = {
    overlay: { position: "fixed", inset: 0, zIndex: 2000, display: "flex", alignItems: "center", justifyContent: "center", background: "rgba(0,0,0,.62)", backdropFilter: "blur(3px)" },
    card: { width: 560, maxWidth: "94vw", maxHeight: "82vh", display: "flex", flexDirection: "column", background: "#101012", border: "1px solid rgba(255,255,255,.09)", borderRadius: 16, boxShadow: "0 24px 64px rgba(0,0,0,.55)", overflow: "hidden", color: "#e4e4e7" },
    head: { display: "flex", alignItems: "center", gap: 8, padding: "14px 16px", borderBottom: "1px solid rgba(255,255,255,.06)" },
    title: { margin: 0, fontSize: 15, fontWeight: 600, flex: 1 },
    x: { background: "transparent", border: "none", color: "#a1a1aa", fontSize: 16, cursor: "pointer", lineHeight: 1, padding: 4 },
    hint: { margin: "14px 16px 0", padding: "10px 12px", fontSize: 12.5, lineHeight: 1.55, color: "#a1a1aa", background: "rgba(255,255,255,.04)", border: "1px solid rgba(255,255,255,.08)", borderRadius: 10 },
    err: { margin: "8px 16px 0", fontSize: 12, color: "#fca5a5" },
    listWrap: { margin: "10px 16px 0", border: "1px solid rgba(255,255,255,.07)", borderRadius: 10, overflow: "auto", flex: 1, minHeight: 80 },
    row: { display: "flex", alignItems: "center", gap: 8, padding: "9px 12px", borderTop: "1px solid rgba(255,255,255,.05)" },
    input: { flex: 1, minWidth: 0, background: "#161618", border: "1px solid rgba(255,255,255,.1)", borderRadius: 8, padding: "7px 10px", color: "#e4e4e7", fontSize: 13, outline: "none" },
    id: { fontSize: 11, color: "#71717a", fontFamily: "ui-monospace,SFMono-Regular,Menlo,monospace", minWidth: 108 },
    ico: { background: "transparent", border: "none", color: "#a1a1aa", fontSize: 13, cursor: "pointer", padding: "3px 6px", borderRadius: 6 },
    foot: { display: "flex", alignItems: "center", gap: 10, padding: "12px 16px 16px" },
    save: { background: "rgba(255,255,255,.1)", border: "none", borderRadius: 9, padding: "9px 18px", color: "#fff", fontSize: 13, fontWeight: 500, cursor: "pointer" },
    ok: { fontSize: 12, color: "#86efac" },
    muted: { padding: 16, textAlign: "center", color: "#71717a", fontSize: 12.5 },
  };

  return createPortal(
    <div style={S.overlay} onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }} data-id="PanelMenuModal">
      <div style={S.card} onClick={(e) => e.stopPropagation()}>
        <div style={S.head}>
          <h2 style={S.title}>{tr("panelMenu.title", "面板菜单")}</h2>
          <button type="button" style={S.x} onClick={onClose} aria-label="close">✕</button>
        </div>
        <div style={S.hint}>
          {tr("panelMenu.hint", '配置标签栏右上角「+」下拉里的面板项:拖动顺序、改名、关掉不用的。每项都对应一个内置面板页,不能新增。')}
        </div>
        {err && <div style={S.err}>{err}</div>}
        <div style={S.listWrap}>
          {rows === null ? (
            <div style={S.muted}>{tr("panelMenu.loading", "加载中…")}</div>
          ) : !api ? (
            <div style={S.muted}>{tr("panelMenu.noBridge", "需要在 CiCy Desktop 中打开才能配置")}</div>
          ) : rows.length === 0 ? (
            <div style={S.muted}>{tr("panelMenu.empty", "暂无")}</div>
          ) : (
            rows.map((r, i) => (
              <div key={r.id} style={S.row} data-id="panel-menu-row">
                <input
                  type="checkbox"
                  checked={r.enabled !== false}
                  onChange={() => toggle(i)}
                  title={tr("panelMenu.toggle", "在菜单中显示")}
                />
                <input
                  style={{ ...S.input, opacity: r.enabled === false ? 0.5 : 1 }}
                  value={r.title || ""}
                  onChange={(e) => rename(i, e.target.value)}
                  placeholder={r.id}
                />
                <span style={S.id}>{r.id}</span>
                <button type="button" style={S.ico} onClick={() => move(i, -1)} disabled={i === 0} title={tr("panelMenu.up", "上移")}>↑</button>
                <button type="button" style={S.ico} onClick={() => move(i, 1)} disabled={i === rows.length - 1} title={tr("panelMenu.down", "下移")}>↓</button>
              </div>
            ))
          )}
        </div>
        <div style={S.foot}>
          <button type="button" data-id="panel-menu-save" style={{ ...S.save, opacity: busy || !api || !rows ? 0.5 : 1 }} onClick={doSave} disabled={busy || !api || !rows}>
            {busy ? tr("panelMenu.saving", "保存中…") : tr("panelMenu.save", "保存")}
          </button>
          {saved && <span style={S.ok}>{tr("panelMenu.saved", "已保存,菜单立即生效")}</span>}
        </div>
      </div>
    </div>,
    document.body,
  );
}

function TrustedSitesModal({ onClose }) {
  const [rows, setRows] = useState(null);   // [{host, builtin}] | null(loading)
  const [input, setInput] = useState("");
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState("");
  const api = (typeof window !== "undefined" && window.cicy && window.cicy.trustedOrigins) || null;

  const load = useCallback(async () => {
    try { setRows((api && (await api.list())) || []); } catch { setRows([]); }
  }, [api]);
  useEffect(() => { load(); }, [load]);

  const doAdd = async () => {
    const v = input.trim();
    if (!v || busy || !api) return;
    setBusy(true); setErr("");
    try {
      const r = await api.add(v);
      if (r && r.ok === false) setErr(r.error || tr("trustedSites.addFailed", "添加失败"));
      else { setInput(""); setRows((r && r.origins) || (await api.list())); }
    } catch (e) { setErr(String((e && e.message) || e)); }
    finally { setBusy(false); }
  };
  const doRemove = async (host) => {
    if (busy || !api) return;
    setBusy(true); setErr("");
    try {
      const r = await api.remove(host);
      if (r && r.ok === false) setErr(r.error || tr("trustedSites.removeFailed", "删除失败"));
      else setRows((r && r.origins) || (await api.list()));
    } catch (e) { setErr(String((e && e.message) || e)); }
    finally { setBusy(false); }
  };

  const S = {
    overlay: { position: "fixed", inset: 0, zIndex: 2000, display: "flex", alignItems: "center", justifyContent: "center", background: "rgba(0,0,0,.62)", backdropFilter: "blur(3px)" },
    card: { width: 560, maxWidth: "94vw", maxHeight: "82vh", display: "flex", flexDirection: "column", background: "#101012", border: "1px solid rgba(255,255,255,.09)", borderRadius: 16, boxShadow: "0 24px 64px rgba(0,0,0,.55)", overflow: "hidden", color: "#e4e4e7" },
    head: { display: "flex", alignItems: "center", gap: 8, padding: "14px 16px", borderBottom: "1px solid rgba(255,255,255,.06)" },
    title: { margin: 0, fontSize: 15, fontWeight: 600, flex: 1 },
    x: { background: "transparent", border: "none", color: "#a1a1aa", fontSize: 16, cursor: "pointer", lineHeight: 1, padding: 4 },
    warn: { margin: "14px 16px 0", padding: "10px 12px", fontSize: 12.5, lineHeight: 1.55, color: "#fca5a5", background: "rgba(239,68,68,.08)", border: "1px solid rgba(239,68,68,.25)", borderRadius: 10 },
    addRow: { display: "flex", gap: 8, padding: "12px 16px 4px" },
    input: { flex: 1, minWidth: 0, background: "#161618", border: "1px solid rgba(255,255,255,.1)", borderRadius: 9, padding: "9px 11px", color: "#e4e4e7", fontSize: 13, outline: "none" },
    addBtn: { background: "rgba(255,255,255,.1)", border: "none", borderRadius: 9, padding: "0 16px", color: "#fff", fontSize: 13, fontWeight: 500, cursor: "pointer" },
    err: { margin: "6px 16px 0", fontSize: 12, color: "#fca5a5" },
    listWrap: { margin: "10px 16px 16px", border: "1px solid rgba(255,255,255,.07)", borderRadius: 10, overflow: "auto", flex: 1, minHeight: 80 },
    row: { display: "flex", alignItems: "center", gap: 10, padding: "9px 12px", borderTop: "1px solid rgba(255,255,255,.05)" },
    host: (b) => ({ flex: 1, fontFamily: "ui-monospace,SFMono-Regular,Menlo,monospace", fontSize: 13, color: b ? "#71717a" : "#e4e4e7", wordBreak: "break-all" }),
    tag: { fontSize: 11, color: "#71717a", background: "rgba(255,255,255,.05)", borderRadius: 6, padding: "2px 7px" },
    rm: { background: "transparent", border: "none", color: "#a1a1aa", fontSize: 12, cursor: "pointer", padding: "3px 6px", borderRadius: 6 },
    muted: { padding: "16px", textAlign: "center", color: "#71717a", fontSize: 12.5 },
  };

  return createPortal(
    <div style={S.overlay} onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }} data-id="TrustedSitesModal">
      <div style={S.card} onClick={(e) => e.stopPropagation()}>
        <div style={S.head}>
          <h2 style={S.title}>{tr("trustedSites.title", "受信任站点")}</h2>
          <button type="button" style={S.x} onClick={onClose} aria-label="close">✕</button>
        </div>
        <div style={S.warn}>
          {tr("trustedSites.warn", "⚠ 列表中的站点可以在你的电脑上执行命令(exec)。只添加你完全信任的地址。")}
        </div>
        <div style={S.addRow}>
          <input
            data-id="trusted-sites-input"
            style={S.input}
            value={input}
            onChange={(e) => setInput(e.target.value)}
            onKeyDown={(e) => { if (e.key === "Enter") doAdd(); }}
            placeholder={tr("trustedSites.placeholder", "添加站点，如 app.cicy-ai.com 或 my-cloud.example.org")}
          />
          <button type="button" data-id="trusted-sites-add" style={{ ...S.addBtn, opacity: busy || !input.trim() ? 0.5 : 1 }} onClick={doAdd} disabled={busy || !input.trim()}>
            {tr("trustedSites.add", "添加")}
          </button>
        </div>
        {err && <div style={S.err}>{err}</div>}
        <div style={S.listWrap}>
          {rows === null ? (
            <div style={S.muted}>{tr("trustedSites.loading", "加载中…")}</div>
          ) : rows.length === 0 ? (
            <div style={S.muted}>{tr("trustedSites.empty", "暂无")}</div>
          ) : (
            rows.map((r) => (
              <div key={r.host} style={S.row} data-id="trusted-sites-row">
                <span style={S.host(r.builtin)}>{r.host}</span>
                {r.builtin ? (
                  <span style={S.tag}>{tr("trustedSites.builtin", "系统")}</span>
                ) : (
                  <button type="button" style={S.rm} onClick={() => doRemove(r.host)} disabled={busy}>
                    {tr("trustedSites.remove", "删除")}
                  </button>
                )}
              </div>
            ))
          )}
        </div>
      </div>
    </div>,
    document.body,
  );
}

// Read-only viewer for the RPC audit log (~/cicy-ai/db/rpc-audit.log): every
// electronRPC call + every authorization decision (incl. temporary 本次允许 / 允许
// 一次) + allowlist edit. Backed by window.cicy.rpcAudit.tail(); newest-first,
// refreshable. Review-only — there is no mutation path.
function AuditLogModal({ onClose }) {
  const [entries, setEntries] = useState(null); // [] | null(loading)
  const [err, setErr] = useState("");
  const [logPath, setLogPath] = useState("");
  const [busy, setBusy] = useState(false);
  const api = (typeof window !== "undefined" && window.cicy && window.cicy.rpcAudit) || null;

  const load = useCallback(async () => {
    setBusy(true); setErr("");
    try {
      const r = api && (await api.tail(400));
      if (!r || r.ok === false) { setErr((r && r.error) || tr("audit.loadFailed", "读取失败")); setEntries([]); }
      else { setEntries(r.entries || []); setLogPath(r.path || ""); }
    } catch (e) { setErr(String((e && e.message) || e)); setEntries([]); }
    finally { setBusy(false); }
  }, [api]);
  useEffect(() => { load(); }, [load]);

  const [filter, setFilter] = useState("all"); // all | rpc | auth
  const [query, setQuery] = useState("");

  const fmtTime = (ts) => { try { return new Date(ts).toLocaleString(); } catch { return ts || ""; } };
  const badge = (e) => {
    if (e.kind === "auth") {
      const deny = /deny/.test(e.decision || "");
      return { text: e.decision || "auth", color: deny ? "#fca5a5" : "#86efac", bg: deny ? "rgba(239,68,68,.14)" : "rgba(34,197,94,.14)" };
    }
    if (e.kind === "rpc") {
      const ok = e.ok !== false && !e.error;
      return { text: ok ? "ok" : "err", color: ok ? "#86efac" : "#fca5a5", bg: ok ? "rgba(34,197,94,.14)" : "rgba(239,68,68,.14)" };
    }
    return { text: e.kind || "log", color: "#a1a1aa", bg: "rgba(255,255,255,.06)" };
  };
  const opText = (e) => {
    if (e.kind === "auth") return `${e.gate || ""}${e.decision ? " · " + e.decision : ""}`;
    if (e.kind === "rpc") return `${e.tool || ""}${e.dangerous ? " ⚠" : ""}`;
    return e.kind || "";
  };
  const detailText = (e) => e.error || e.args || (e.kind === "rpc" ? e.channel : "") || "";

  const all = entries || [];
  const q = query.trim().toLowerCase();
  const view = all.filter((e) => {
    if (filter !== "all" && e.kind !== filter) return false;
    if (!q) return true;
    return [e.origin, e.host, e.tool, e.gate, e.decision, e.channel, e.args, e.error, e.kind]
      .filter(Boolean).join(" ").toLowerCase().includes(q);
  });

  const COLS = "186px 104px minmax(160px,1.1fr) minmax(150px,1.1fr) minmax(220px,1.8fr)";
  const S = {
    overlay: { position: "fixed", inset: 0, zIndex: 2000, display: "flex", alignItems: "center", justifyContent: "center", background: "rgba(0,0,0,.66)", backdropFilter: "blur(4px)" },
    card: { width: "96vw", height: "92vh", maxWidth: 1480, display: "flex", flexDirection: "column", background: "#0d0d0f", border: "1px solid rgba(255,255,255,.09)", borderRadius: 18, boxShadow: "0 32px 80px rgba(0,0,0,.6)", overflow: "hidden", color: "#e4e4e7" },
    head: { display: "flex", alignItems: "center", gap: 14, padding: "20px 24px", borderBottom: "1px solid rgba(255,255,255,.07)" },
    titleWrap: { flex: 1, minWidth: 0 },
    title: { margin: 0, fontSize: 21, fontWeight: 650, letterSpacing: .2 },
    subtitle: { margin: "3px 0 0", fontSize: 12.5, color: "#71717a", fontFamily: "ui-monospace,SFMono-Regular,Menlo,monospace", wordBreak: "break-all" },
    count: { fontSize: 12.5, color: "#a1a1aa", whiteSpace: "nowrap" },
    refresh: { background: "rgba(255,255,255,.1)", border: "none", borderRadius: 9, padding: "9px 16px", color: "#fff", fontSize: 13, fontWeight: 500, cursor: "pointer" },
    x: { background: "transparent", border: "none", color: "#a1a1aa", fontSize: 20, cursor: "pointer", lineHeight: 1, padding: 6 },
    toolbar: { display: "flex", alignItems: "center", gap: 10, padding: "14px 24px", borderBottom: "1px solid rgba(255,255,255,.05)" },
    chips: { display: "flex", gap: 6 },
    chip: (on) => ({ background: on ? "rgba(255,255,255,.14)" : "transparent", border: "1px solid rgba(255,255,255,.12)", borderRadius: 999, padding: "6px 16px", color: on ? "#fff" : "#a1a1aa", fontSize: 13, cursor: "pointer" }),
    search: { flex: 1, minWidth: 0, background: "#161618", border: "1px solid rgba(255,255,255,.1)", borderRadius: 10, padding: "10px 14px", color: "#e4e4e7", fontSize: 13.5, outline: "none" },
    err: { margin: "10px 24px 0", fontSize: 12.5, color: "#fca5a5" },
    tableWrap: { flex: 1, overflow: "auto", margin: "0" },
    theadRow: { position: "sticky", top: 0, zIndex: 1, display: "grid", gridTemplateColumns: COLS, gap: 16, padding: "12px 24px", background: "#141417", borderBottom: "1px solid rgba(255,255,255,.08)", fontSize: 11.5, letterSpacing: .6, textTransform: "uppercase", color: "#71717a", fontWeight: 600 },
    row: { display: "grid", gridTemplateColumns: COLS, gap: 16, padding: "13px 24px", borderBottom: "1px solid rgba(255,255,255,.045)", alignItems: "center" },
    time: { fontSize: 12.5, color: "#a1a1aa", fontFamily: "ui-monospace,SFMono-Regular,Menlo,monospace", whiteSpace: "nowrap" },
    badge: (b) => ({ justifySelf: "start", fontSize: 11.5, color: b.color, background: b.bg, borderRadius: 7, padding: "3px 10px", whiteSpace: "nowrap", fontWeight: 500 }),
    cell: { fontSize: 13, color: "#d4d4d8", fontFamily: "ui-monospace,SFMono-Regular,Menlo,monospace", wordBreak: "break-all" },
    detail: { fontSize: 12.5, color: "#8b8b93", wordBreak: "break-all" },
    muted: { padding: "60px 24px", textAlign: "center", color: "#71717a", fontSize: 14 },
  };
  const Th = (t) => <div>{t}</div>;

  return createPortal(
    <div style={S.overlay} onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }} data-id="AuditLogModal">
      <div style={S.card} onClick={(e) => e.stopPropagation()}>
        <div style={S.head}>
          <div style={S.titleWrap}>
            <h2 style={S.title}>{tr("audit.title", "审计日志")}</h2>
            {logPath && <p style={S.subtitle}>{logPath}</p>}
          </div>
          <span style={S.count}>{tr("audit.count", "共")} {view.length}{filter !== "all" || q ? ` / ${all.length}` : ""}</span>
          <button type="button" data-id="audit-refresh" style={{ ...S.refresh, opacity: busy ? 0.5 : 1 }} onClick={load} disabled={busy}>
            {tr("audit.refresh", "刷新")}
          </button>
          <button type="button" style={S.x} onClick={onClose} aria-label="close">✕</button>
        </div>
        <div style={S.toolbar}>
          <div style={S.chips}>
            <button type="button" style={S.chip(filter === "all")} onClick={() => setFilter("all")}>{tr("audit.all", "全部")}</button>
            <button type="button" style={S.chip(filter === "rpc")} onClick={() => setFilter("rpc")}>{tr("audit.rpc", "RPC 调用")}</button>
            <button type="button" style={S.chip(filter === "auth")} onClick={() => setFilter("auth")}>{tr("audit.auth", "授权决定")}</button>
          </div>
          <input
            data-id="audit-search"
            style={S.search}
            value={query}
            onChange={(e) => setQuery(e.target.value)}
            placeholder={tr("audit.search", "搜索来源 / 工具 / 命令…")}
          />
        </div>
        {err && <div style={S.err}>{err}</div>}
        <div style={S.tableWrap}>
          <div style={S.theadRow}>
            {Th(tr("audit.colTime", "时间"))}
            {Th(tr("audit.colType", "类型"))}
            {Th(tr("audit.colSource", "来源"))}
            {Th(tr("audit.colOp", "操作"))}
            {Th(tr("audit.colDetail", "详情"))}
          </div>
          {entries === null ? (
            <div style={S.muted}>{tr("audit.loading", "加载中…")}</div>
          ) : view.length === 0 ? (
            <div style={S.muted}>{all.length === 0 ? tr("audit.empty", "暂无审计记录") : tr("audit.noMatch", "无匹配记录")}</div>
          ) : (
            view.map((e, i) => {
              const b = badge(e);
              return (
                <div key={i} style={S.row} data-id="audit-row">
                  <span style={S.time}>{fmtTime(e.ts)}</span>
                  <span style={S.badge(b)}>{b.text}</span>
                  <span style={S.cell}>{e.origin || e.host || "—"}</span>
                  <span style={S.cell}>{opText(e) || "—"}</span>
                  <span style={S.detail}>{detailText(e) || "—"}</span>
                </div>
              );
            })
          )}
        </div>
      </div>
    </div>,
    document.body,
  );
}

function Header({ me, welcome, onLogout, mitmTeam, guest = false, onLogin, hub }) {
  // 首页只认 CiCy Hub 账号:头像/名字 = hub 邮箱;未登录 hub = 访客。
  const hubIn = !!hub?.loggedIn;
  const hubEmail = hubIn ? String(hub.owner || "") : "";
  const name = hubEmail || "…";
  const initials = (name || "?").slice(0, 1).toUpperCase();
  guest = !hubIn;
  me = hubIn ? { email: hubEmail } : null;
  const [open, setOpen] = useState(false);
  // 账号版本档位(个人版/团队版/企业版)—— 账号级,来自 tunnelStatus()=GET /api/gateway/tunnels
  // 的 tier 字段(personal|team|enterprise)。~分钟级刷新以反映升/降档。
  const [tier, setTier] = useState("");
  useEffect(() => {
    if (!window.cicy?.sidecar?.tunnelStatus) return;
    let stop = false;
    const load = () => window.cicy.sidecar.tunnelStatus().then((r) => {
      if (stop || !r?.ok) return;
      // 优先用云端 tier;老后端(v2.2.72 前)没这字段 → 按 w-10122 规则从 tunnel_limit 推:
      // 0→personal / N>0→team / -1→enterprise。
      let tv = r.tier;
      if (!tv) {
        const lim = Number(r.tunnelLimit ?? r.tunnel_limit);
        if (Number.isFinite(lim)) tv = lim < 0 ? "enterprise" : lim > 0 ? "team" : "personal";
      }
      if (tv) setTier(String(tv));
    }).catch(() => {});
    load();
    const id = setInterval(load, 60000);
    return () => { stop = true; clearInterval(id); };
  }, []);
  const [trustOpen, setTrustOpen] = useState(false);
  const [auditOpen, setAuditOpen] = useState(false);
  const [termsOpen, setTermsOpen] = useState(false);
  const [panelOpen, setPanelOpen] = useState(false);
  // The team is asked for at login, but a machine that signed in before that
  // existed never gets asked — and there was nowhere to see or change it
  // afterwards, so it stayed anonymous forever. Surface it in the menu.
  const [myTeam, setMyTeam] = useState(null); // null = still reading
  // 每次**打开这个菜单**都重读一次真实名字,不要只在挂载时读一次。
  // 名字可能在别处被改(机器管理页远程改名、或人工改了 team.txt),只读一次的话
  // 这里会一直显示旧名字,看着像「改不了名」(实测:远程改成 limeng-console 后,
  // 这台机器的菜单仍显示 MHZFU)。
  useEffect(() => {
    if (!open) return;
    let on = true;
    readTeam().then((t) => on && setMyTeam(t || ""));
    return () => { on = false; };
  }, [hubIn, open]);
  // A team belongs to a signed-in machine: it is stated at login and given up
  // with it. Left behind, it makes a signed-out box claim an identity it no
  // longer holds — which is exactly how the wrong machine gets acted on.
  useEffect(() => {
    if (hub?.available && hub.status && !hubIn && myTeam) { writeTeam("").then(() => setMyTeam("")).catch(() => {}); }
  }, [hub?.available, hub?.status, hubIn, myTeam]);
  // 改机器名(账号菜单里那一行)。写 global.json 的 desktopTeam,然后 relabel —— 让车队通道
  // 带着新名字重连一次,hub/机器管理页立刻显示新名字,不用重启 app。
  const [teamEditing, setTeamEditing] = useState(false);
  const [teamSaving, setTeamSaving] = useState(false);
  const commitTeam = useCallback(async (raw) => {
    setTeamEditing(false);
    const next = stripTeamPrefix(String(raw || "").trim());
    const cur = stripTeamPrefix(myTeam || "");
    if (next === cur) return;                              // 没改就别写
    const bad = (msg) => toast.show({ id: "team-rename", status: "error", ttl: 6000, message: msg });
    if (next && !TEAM_RE.test(next)) { bad(tr("machineName.badFormat", "机器名只能用字母、数字和 . _ -")); return; }
    // 查重:车队里不能有两台同名机器。hub 是按 team 名认机器的,同名的后来者会把前一台
    // 顶下线(superseded),两台会反复互踢 —— 所以这里必须拦住。
    // 比的是加前缀后的完整名(desktop-xs-1009),大小写不敏感;自己原来的名字不算冲突。
    const taken = hub?.takenNames || [];
    if (next && taken.length) {
      const want = withTeamPrefix(next).toLowerCase();
      const mine = withTeamPrefix(cur).toLowerCase();
      // 只和**机器名**(desktop-* 那些)比。hub 上同时列着 cicy-code 实例和机器,
      // 而它们本来就该同名(实例 xs-2002 配机器 desktop-xs-2002)—— 拿实例名来判重名,
      // 等于把唯一正确的命名方式给禁了(实测:改名 xs-2002 一直被拒)。
      const clash = taken.some((n) => {
        const t = String(n || "").trim().toLowerCase();
        if (!t.startsWith(TEAM_PREFIX)) return false;
        return t !== mine && t === want;
      });
      if (clash) { bad(tr("machineName.taken", "「{{n}}」已经被另一台机器用了,换一个", { n: next })); return; }
    }
    setTeamSaving(true);
    try {
      const saved = await writeTeam(next);        // 回读到的真实名字(可能被更高优先级的来源盖住)
      setMyTeam(saved || "");
      if (next && stripTeamPrefix(saved || "") !== next) {
        bad(tr("machineName.overridden", "改名没生效:这台机器的名字由桌面上的「{{n}}.txt」决定,当前仍是「{{cur}}」",
          { n: stripTeamPrefix(saved || "").replace(/^xs-/, ""), cur: stripTeamPrefix(saved || "") }));
      }
      try { window.__cicyFleetRelabel && window.__cicyFleetRelabel(); } catch {}
      try { hub?.refresh && hub.refresh(); } catch {}      // 让占用名单跟上
    } catch {
      bad(tr("machineName.failed", "机器名没保存成功"));
    } finally { setTeamSaving(false); }
  }, [myTeam, hub]);
  const [checkingUpd, setCheckingUpd] = useState(false);
  const [appVer, setAppVer] = useState("");
  const wrap = useRef(null);
  // cicy-desktop's own version, shown at the very bottom of this menu.
  // app.getVersion() returns { desktop, cicyCodeRef, electron, node } — pick the
  // desktop version string (was rendering as [object Object]).
  useEffect(() => {
    let alive = true;
    window.cicy?.app?.getVersion?.().then((v) => {
      if (!alive) return;
      setAppVer(typeof v === "string" ? v : String(v?.desktop || ""));
    }).catch(() => {});
    return () => { alive = false; };
  }, []);
  // Click-outside closes the dropdown (mirrors LocalTeamCard's ⋯ menu).
  useEffect(() => {
    if (!open) return;
    const onDoc = (e) => { if (wrap.current && !wrap.current.contains(e.target)) setOpen(false); };
    document.addEventListener("mousedown", onDoc);
    return () => document.removeEventListener("mousedown", onDoc);
  }, [open]);
  // Cloud dash pages: query-param routed, opened via a one-time handoff ticket
  // (no long-term token in the URL — see openCloudPage).
  const goDash = (query) => { openCloudPage(query); setOpen(false); };
  const copyEmail = async () => {
    const email = String(hubEmail || "").trim();
    if (!email) return;
    try {
      if (window.cicy?.clipboard?.write) await window.cicy.clipboard.write(email);
      else await navigator.clipboard.writeText(email);
      setOpen(false);
      toast.show({ message: tr("userMenu.emailCopied", "邮箱已复制"), status: "done", ttl: 2500 });
    } catch {
      toast.show({ message: tr("userMenu.emailCopyFailed", "邮箱复制失败"), status: "error", ttl: 3500 });
    }
  };
  // 主动检查更新:有新版 → updater 广播 → 顶部 banner 出现;最新/出错 → toast 反馈。
  const checkUpdate = async () => {
    setOpen(false); setCheckingUpd(true);
    toast.show({ id: "app-update", message: tr("updateBanner.checking", "正在检查更新…"), status: "running" });
    try {
      const s = await window.cicy?.app?.checkUpdate?.();
      if (s?.status === "available") toast.show({ id: "app-update", message: tr("updateBanner.available", "发现新版本 v{{v}}", { v: s.version }), status: "done", ttl: 4000 });
      else if (s?.status === "up-to-date") toast.show({ id: "app-update", message: tr("updateBanner.upToDate", "已是最新版本 v{{v}}", { v: s.version || s.current }), status: "done", ttl: 3000 });
      else toast.show({ id: "app-update", message: tr("updateBanner.error", "更新失败") + (s?.error ? `:${s.error}` : ""), status: "error", ttl: 5000 });
    } catch (e) { toast.show({ id: "app-update", message: e.message, status: "error", ttl: 5000 }); }
    finally { setCheckingUpd(false); }
  };
  // 用户版本徽章(个人版/团队版/企业版)—— tier 来自 tunnelStatus()。规范值映射,未知原样,空不渲染。
  const planTxt = (() => {
    const raw = String(tier || "").toLowerCase().trim();
    if (!raw) return "";
    const map = { personal: tr("plan.personal", "个人版"), team: tr("plan.team", "团队版"), enterprise: tr("plan.enterprise", "企业版") };
    return map[raw] || raw;
  })();
  return (
    <>
    <header className="topbar">
      {/* logo 移到 tab-shell 的「我的团队」标签(CICY_LOGO),topbar 不再重复显示品牌 */}
      <div className="user-chip" data-id="UserChip" ref={wrap}>
        {welcome && <span className="welcome">{welcome}</span>}
        {/* 账号版本徽标 —— user-chip 行内的独立元素(不写进 avatar 按钮,点它不展开菜单),
            靠 user-chip 的 inline-flex 排在头像左边 */}
        {planTxt && (
          <span data-id="UserChip-plan" className="plan-badge" title={tr("plan.hint", "当前账号版本")}
            style={{ fontSize: 11, fontWeight: 600, lineHeight: 1.6, padding: "2px 9px", borderRadius: 999, whiteSpace: "nowrap", background: "var(--accent-soft, rgba(120,140,255,.16))", color: "var(--accent, #9db0ff)" }}>
            {planTxt}
          </span>
        )}
        {guest ? (
          // 访客:同样的头像下拉(受信任站点/审计/协议/检查更新都不依赖登录),
          // 菜单里用「登录」替代「退出」;账号相关项(邮箱/钱包)不显示。
          <button type="button" data-id="UserChip-trigger" className={`user-chip__trigger${open ? " is-open" : ""}`}
            onClick={() => setOpen((v) => !v)}>
            <div className="avatar" style={{ background: "var(--border, #2c2f36)", color: "var(--muted, #8b8b92)" }}>?</div>
            <span className="user-name">{tr("cicyHub.login", "登录 CiCy Hub")}</span>
            <span className="user-chip__caret" aria-hidden>▾</span>
          </button>
        ) : !me ? (
          // 首次打开:profile 还没拉到 → avatar/名字用 skeleton 占位
          <div className="user-chip__trigger user-chip__trigger--skel" data-id="UserChip-skeleton" aria-hidden>
            <div className="skel skel--avatar" />
            <div className="skel skel--name" />
          </div>
        ) : (
        <button
          type="button"
          data-id="UserChip-trigger"
          className={`user-chip__trigger${open ? " is-open" : ""}`}
          onClick={() => setOpen((v) => !v)}
        >
          <div className="avatar">{initials}</div>
          <span className="user-name">{name}</span>
          <span className="user-chip__caret" aria-hidden>▾</span>
        </button>
        )}
        {open && (
          <div className="user-chip__menu" data-id="UserChip-menu" role="menu">
            {!guest && me?.email && (
              <button type="button" data-id="UserChip-email-copy" className="user-chip__menu-item" title={tr("userMenu.copyEmail", "点击复制邮箱")} onClick={copyEmail}
                style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
                {me.email}
              </button>
            )}
            {hubIn && myTeam !== null && (
              /* 这台机器在车队里的名字 —— 现在可以在这里直接改。
                 以前只读:登录时问一次,登录前装的机器根本没被问过,改都没地方改,
                 于是永远匿名(车队列表里就只能显示系统机器名或随机短码,认不出是哪台)。
                 存的是 global.json 的 desktopTeam,存/显示带 desktop- 前缀,输入时不用带。
                 改完立刻 relabel:重连一次把新名字报给 hub,不用重启。 */
              <div data-id="UserChip-team"
                title={tr("machineName.hint", "这台机器在车队里的名字,点一下就能改")}
                style={{ padding: "0 14px 8px", marginTop: -4, fontSize: 12,
                         display: "flex", alignItems: "center", gap: 6, minHeight: 30,
                         maxWidth: "100%", overflow: "hidden" }}>
                <span style={{ opacity: .5, flex: "none" }}>{tr("machineName.label", "机器名")}</span>
                {teamEditing ? (
                  <input
                    data-id="UserChip-team-input"
                    autoFocus
                    size={1}
                    defaultValue={stripTeamPrefix(myTeam)}
                    placeholder={tr("machineName.ph", "例如 xs-1009")}
                    spellCheck={false}
                    onClick={(e) => e.stopPropagation()}
                    onBlur={(e) => commitTeam(e.target.value)}
                    onKeyDown={(e) => {
                      if (e.nativeEvent.isComposing || e.keyCode === 229) return;
                      if (e.key === "Enter") commitTeam(e.currentTarget.value);
                      else if (e.key === "Escape") setTeamEditing(false);
                    }}
                    style={{ ...TEAM_FIELD_STYLE, borderColor: "#3b82f6", background: "#0d1117", color: "#e6edf3" }}
                  />
                ) : (
                  <button type="button" data-id="UserChip-team-edit"
                    onClick={(e) => { e.stopPropagation(); setTeamEditing(true); }}
                    style={{ ...TEAM_FIELD_STYLE, textAlign: "left", cursor: "text", background: "transparent",
                             color: myTeam ? "inherit" : "#58a6ff", opacity: myTeam ? .7 : 1,
                             overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
                    {teamSaving ? tr("machineName.saving", "保存中…")
                      : myTeam ? stripTeamPrefix(myTeam)
                      : tr("machineName.unset", "未命名 · 点击设置")}
                  </button>
                )}
              </div>
            )}
            {/* 没登录时,「登录 CiCy Hub」是这个菜单里最该点的一项 —— 放最上面(ID 之上),
                不要埋在「检查更新」下面。登录后这一项自动消失。 */}
            {hub?.available && !hubIn && (
              <button type="button" data-id="UserChip-hub-login" className="user-chip__menu-item" onClick={() => { setOpen(false); hub.openLogin(); }}>
                {tr("cicyHub.login", "登录 CiCy Hub")}
              </button>
            )}
            {(() => { let sid = ""; try { sid = getShortId(); } catch {} return sid ? (
              <div data-id="UserChip-machine-id" title={tr("deviceId.hint", "本机 ID")}
                style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8,
                         padding: "6px 14px 8px", fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace" }}>
                <span style={{ opacity: .5, fontSize: 12 }}>ID</span>
                <span style={{ fontSize: 16, fontWeight: 700, letterSpacing: 1.5 }}>{sid}</span>
              </div>
            ) : null; })()}
            <div className="user-chip__menu-sep" aria-hidden />
            <button type="button" data-id="UserChip-trusted-sites" className="user-chip__menu-item" onClick={() => { setOpen(false); setTrustOpen(true); }}>
              {tr("trustedSites.menu", "受信任站点")}
            </button>
            <button type="button" data-id="UserChip-panel-menu" className="user-chip__menu-item" onClick={() => { setOpen(false); setPanelOpen(true); }}>
              {tr("panelMenu.menu", "面板菜单")}
            </button>
            <button type="button" data-id="UserChip-audit-log" className="user-chip__menu-item" onClick={() => { setOpen(false); setAuditOpen(true); }}>
              {tr("audit.menu", "审计日志")}
            </button>
            <button type="button" data-id="UserChip-terms" className="user-chip__menu-item" onClick={() => { setOpen(false); setTermsOpen(true); }}>
              {tr("firstRunTerms.menu", "用户协议")}
            </button>
            <button type="button" data-id="UserChip-check-update" className="user-chip__menu-item" disabled={checkingUpd} onClick={checkUpdate}>
              {checkingUpd ? tr("updateBanner.checkingShort", "检查中…") : tr("updateBanner.checkBtn", "检查更新")}
            </button>
            {/* HTTPS 审计入口暂时隐藏 */}
            {false && mitmTeam && (
              <div className="user-chip__menu-mitm" data-id="UserChip-mitm" onClick={(e) => e.stopPropagation()}>
                <MitmConsentCard team={mitmTeam} variant="menu" />
              </div>
            )}
            <div className="user-chip__menu-sep" aria-hidden />
            {hubIn && (
              <button type="button" data-id="UserChip-hub-logout" className="user-chip__menu-item is-danger" onClick={() => { setOpen(false); hub.logout(); }}>
                {tr("userMenu.logout", "退出")}
              </button>
            )}
            <div className="user-chip__menu-version" data-id="UserChip-version">
              CiCy Desktop {appVer ? `v${appVer}` : "…"}
            </div>
          </div>
        )}
      </div>
    </header>
    {/* 这些 modal 必须渲染在 .topbar 之外:.topbar 有 backdrop-filter,会成为
        position:fixed 的包含块,放在里面会让 modal 被限制在顶栏区域(位置不对)。 */}
    {trustOpen && <TrustedSitesModal onClose={() => setTrustOpen(false)} />}
    {auditOpen && <AuditLogModal onClose={() => setAuditOpen(false)} />}
    {panelOpen && <PanelMenuModal onClose={() => setPanelOpen(false)} />}
    {termsOpen && <FirstRunTermsGate onClose={() => setTermsOpen(false)} />}
    </>
  );
}

// The machine's own id, shown prominently so it can be read off and quoted
// ("id X has a problem") without digging. Click to copy.
function DesktopIdBadge() {
  const [id, setId] = useState("");
  useEffect(() => { try { setId(getShortId()); } catch {} }, []);
  if (!id) return null;
  // Big and readable — the operator has no mouse, they just read it out.
  return (
    <div data-id="DesktopIdBadge"
      style={{ marginLeft: "auto", display: "inline-flex", alignItems: "center", gap: 8, padding: "4px 14px",
               fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
               color: "var(--text, #e6e6e6)", background: "var(--card, #1b1d22)",
               border: "1px solid var(--border, #2c2f36)", borderRadius: 8, whiteSpace: "nowrap" }}>
      <span style={{ opacity: .5, fontSize: 12 }}>ID</span>
      <span style={{ fontSize: 20, fontWeight: 700, letterSpacing: 2 }}>{id}</span>
    </div>
  );
}

function Section({ title, subtitle, icon, children }) {
  return (
    <section className="section">
      <div className="section-head">
        {icon && <span className="section-icon">{icon}</span>}
        <h2>{title}</h2>
        {subtitle && <span className="section-sub">· {subtitle}</span>}
      </div>
      <div className="section-body">{children}</div>
    </section>
  );
}

// Windows-only: when the local team needs Docker and it isn't ready, this card
// drives the one-click bootstrap (install Docker → load image → start container
// → wait health). Each step shows pending/running/skip/done/error + download %.
// Idempotent + resumable on the backend, so 重试 picks up where it left off.
const DOCKER_STEPS = [
  { key: "install-docker", label: "安装 Docker" },
  { key: "image",          label: "拉取基础镜像" },
  { key: "container",      label: "启动 cicy-code" },
  { key: "health",         label: "本地团队就绪" },
];

// 首启门控:整体条款的第一道同意。未同意不进主界面;读到底部才解锁"同意"。
// 与 MitmConsentCard(HTTPS 审计第二道同意)完全独立 —— 同意条款 ≠ 开启审计。
function FirstRunTermsGate({ onAgree, onClose }) {
  const [scrolledEnd, setScrolledEnd] = useState(false);
  const [busy, setBusy] = useState(false);
  const locale = (window.cicyI18n?.locale || "en").startsWith("zh") ? "zh-CN" : "en";
  const t = (k, fb) => tr(`firstRunTerms.${k}`, fb);
  const review = !!onClose; // opened from the avatar menu to re-read — not the blocking first-run gate
  const html = useMemo(() => mdToHtml(termsForDisplay(locale)), [locale]);

  const onScroll = (e) => {
    const el = e.currentTarget;
    if (el.scrollHeight - el.scrollTop - el.clientHeight < 24) setScrolledEnd(true);
  };
  // Short content that never scrolls → unlock immediately.
  const bodyRef = useRef(null);
  useEffect(() => {
    const el = bodyRef.current;
    if (el && el.scrollHeight <= el.clientHeight + 24) setScrolledEnd(true);
  }, []);

  const agree = async () => {
    if (busy || !scrolledEnd) return;
    setBusy(true);
    try { await window.cicy?.terms?.agree?.(TERMS_VERSION); onAgree?.(); }
    catch { onAgree?.(); } // never trap the user; main also persists
  };
  const decline = () => { try { window.cicy?.terms?.decline?.(); } catch {} };

  return (
    <div
      className={review ? "terms-gate terms-gate--review" : "shell terms-gate"}
      data-id="FirstRunTermsGate"
      style={review ? { position: "fixed", inset: 0, zIndex: 1000, background: "rgba(8,9,14,.72)", backdropFilter: "blur(4px)" } : undefined}
      onClick={review ? (e) => { if (e.target === e.currentTarget) onClose(); } : undefined}
    >
      <div className="glow" aria-hidden />
      <div className="terms-gate__panel">
        <h1 className="terms-gate__title" data-id="FirstRunTermsGate-title">{t("title", "用户协议与授权说明")}</h1>
        <p className="terms-gate__subtitle">{t("subtitle", "使用 CiCy Desktop 前,请阅读并同意以下条款")}</p>

        <div className="terms-gate__body" ref={bodyRef} onScroll={onScroll} data-id="FirstRunTermsGate-body">
          <div className="terms-md" data-id="FirstRunTermsGate-md" dangerouslySetInnerHTML={{ __html: html }} />
        </div>

        {review ? (
          <div className="terms-gate__actions">
            <button data-id="FirstRunTermsGate-close" className="terms-gate__btn" onClick={onClose}>
              {t("close", "关闭")}
            </button>
          </div>
        ) : (
          <>
            {!scrolledEnd && <div className="terms-gate__scrollhint" data-id="FirstRunTermsGate-scrollhint">{t("scrollHint", "请阅读至底部以继续")}</div>}
            <div className="terms-gate__actions">
              <button data-id="FirstRunTermsGate-decline" className="terms-gate__btn terms-gate__btn--ghost" onClick={decline}>
                {t("decline", "不同意并退出")}
              </button>
              <button data-id="FirstRunTermsGate-agree" className="terms-gate__btn" disabled={!scrolledEnd || busy}
                title={!scrolledEnd ? t("mustAgree", "未同意则无法使用本软件。") : ""} onClick={agree}>
                {t("agree", "同意并继续")}
              </button>
            </div>
          </>
        )}
      </div>
    </div>
  );
}

// HTTPS 审计 CA 授权卡片 (合规 opt-in)。绝不首启静默装根证书 (Superfish 红线) —
// 用户在此显式同意后,才由 cicy-code 写入系统根信任库。三态:未授权 / 已授权(可撤销) /
// 处理中。同意走 POST /api/mitm/consent;need_elevation 回退 exec 自提权 install-ca。
function MitmConsentCard({ team, variant }) {
  const [status, setStatus] = useState(undefined); // undefined=loading, null=endpoint absent, {generated,trusted,consent}
  const [busy, setBusy] = useState("");            // "" | enable | disable
  const [error, setError] = useState("");

  const base = (team?.base_url || "").replace(/\/$/, "");
  const token = team?.api_token || "";

  const caFetch = useCallback(async (path, opts = {}) => {
    if (!window.cicy?.cloud?.fetch) throw new Error("bridge missing");
    const r = await window.cicy.cloud.fetch(`${base}${path}`, {
      ...opts,
      headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...(opts.headers || {}) },
    });
    let json = null; try { json = JSON.parse(r.body); } catch {}
    return { ok: r.ok, status: r.status, json };
  }, [base, token]);

  const refresh = useCallback(async () => {
    try {
      const r = await caFetch("/api/mitm/ca-status");
      // 404 / not-ok → endpoint not present (older cicy-code) → hide card.
      setStatus(r.ok && r.json ? r.json : null);
    } catch { setStatus(null); }
  }, [caFetch]);
  useEffect(() => { if (base && token) refresh(); }, [base, token, refresh]);

  // Hide until we know the CA exists. No CA generated (MITM off) → nothing to consent to.
  if (status === undefined) return null;          // still loading
  if (!status || !status.generated) return null;  // endpoint absent or no CA

  const enable = async () => {
    if (busy) return;
    setBusy("enable"); setError("");
    try {
      const r = await caFetch("/api/mitm/consent", { method: "POST", body: JSON.stringify({ enable: true }) });
      // Any "I lack privilege to write the trust store" answer → elevate. Older
      // daemons (esp. macOS) return the raw keychain error instead of the tidy
      // "need_elevation" code, so match those too rather than dumping a scary
      // "security add-trusted-cert: Write permissions error" on the user.
      const elevText = `${r.json?.error || ""} ${r.json?.detail || ""}`;
      const needsElevation = r.json?.error === "need_elevation"
        || (!r.ok && r.status === 403)
        || /need_elevation|add-trusted-cert|write permission|SecCertificate|not permitted|requires admin|administrator/i.test(elevText);
      if (r.ok && r.json?.ok && r.json?.trusted) { await refresh(); }
      else if (needsElevation) {
        // fall back to the self-elevating CLI (OS prompt = the second consent)
        const ex = await window.cicy?.mitm?.caExec?.("install");
        if (ex?.ok) await refresh();
        else setError(/cancel/i.test(ex?.stderr || "") ? tr("mitmConsent.errorAdminDenied", "未获得管理员授权,已取消。") : (ex?.stderr || tr("mitmConsent.errorTitle", "提权失败,请从管理员控制台运行")));
      } else {
        setError(r.json?.error || `失败 (HTTP ${r.status})`);
      }
    } catch (e) { setError(String(e?.message || e)); }
    finally { setBusy(""); }
  };

  const disable = async () => {
    if (busy) return;
    setBusy("disable"); setError("");
    try {
      const r = await caFetch("/api/mitm/consent", { method: "POST", body: JSON.stringify({ enable: false }) });
      if (r.ok && r.json?.ok) await refresh();
      else {
        const ex = await window.cicy?.mitm?.caExec?.("uninstall");
        if (ex?.ok) await refresh(); else setError(ex?.stderr || r.json?.error || "撤销失败");
      }
    } catch (e) { setError(String(e?.message || e)); }
    finally { setBusy(""); }
  };

  const granted = status.consent && status.trusted;
  const partial = status.consent && !status.trusted; // consented but not (re)installed
  const t = (k, fb) => tr(`mitmConsent.${k}`, fb);

  // Menu variant: a single flat row matching the user-chip dropdown items —
  // label + state dot on the left, a quiet on/off toggle on the right. No big
  // card, no portal pill. Used inside the user menu (tip 要和 menu 风格统一).
  if (variant === "menu") {
    const toggle = (e) => {
      e?.stopPropagation?.();
      if (busy) return;
      if (granted) { if (window.confirm(t("revokeConfirm", "撤销后将停止解密审计并清除同意标记。确定?"))) disable(); }
      else enable();
    };
    return (
      <div className="user-chip__mitm" data-id="MitmConsentCard">
        <div className="user-chip__menu-item user-chip__mitm-row"
          title={t("scopeNote", "仅解密 AI 厂商域名,数据留本地,随时可关闭。")}>
          <span className="user-chip__mitm-label">{t("rowLabel", "HTTPS 审计")}</span>
          <button type="button" role="switch" aria-checked={granted ? "true" : "false"}
            data-id="MitmConsentCard-toggle"
            className={`mini-switch${granted ? " is-on" : ""}${busy ? " is-busy" : ""}`}
            disabled={!!busy} onClick={toggle}>
            <span className="mini-switch__knob" />
          </button>
        </div>
        {partial && !busy && <div className="user-chip__mitm-note" data-id="MitmConsentCard-note">{t("partialNote", "已同意但未安装,点开关重试")}</div>}
        {error && <div className="user-chip__mitm-err" data-id="MitmConsentCard-error">{error}</div>}
      </div>
    );
  }

  // 已启用是稳态状态,不是决策 — 收成一个低调的小 pill(一行 + "关闭"),把显眼的
  // 大卡片只留给首次"同意"那一下,不在首页常驻一个大块。
  if (granted || busy === "disable") {
    // Portal to <body> so the fixed pill sits in the ROOT stacking context and
    // paints above the topbar (otherwise it's trapped in the content stack and the
    // 顶栏 user chip covers it).
    return createPortal(
      <div data-id="MitmConsentCard" className="mitm-pill" title={t("grantedDesc", "HTTPS 审计已开启,仅对 CiCy 启动的 AI 工具生效;随时可关闭。")}>
        <span className="mitm-pill__dot" data-busy={busy ? "1" : "0"} />
        <span className="mitm-pill__text" data-id="MitmConsentCard-title">
          {busy === "disable" ? t("processingRevoke", "正在关闭…") : t("statePillOn", "HTTPS 审计已开启")}
        </span>
        {!busy && (
          <button type="button" data-id="MitmConsentCard-revoke" className="mitm-pill__off"
            onClick={() => { if (window.confirm(t("revokeConfirm", "撤销后将停止解密审计并清除同意标记。确定?"))) disable(); }}>
            {t("turnOff", "关闭")}
          </button>
        )}
      </div>,
      document.body
    );
  }

  return (
    <div data-id="MitmConsentCard" className={`mitm-card${granted ? " mitm-card--on" : ""}`}>
      <div className="mitm-card__head">
        <span className="mitm-card__dot" data-state={granted ? "on" : partial ? "warn" : "off"} />
        <span className="mitm-card__title" data-id="MitmConsentCard-title">
          {busy ? t("stateProcessingTitle", "处理中…")
            : granted ? t("stateGrantedTitle", "已启用")
            : `${t("cardTitle", "HTTPS 流量本地审计")}${partial ? " — " + t("retry", "重试") : ""}`}
        </span>
      </div>
      <p className="mitm-card__desc" data-id="MitmConsentCard-desc">
        {granted ? t("grantedDesc", "HTTPS 审计已开启,仅对 CiCy 启动的 AI 工具(claude / codex 等)生效;随时可关闭。") : t("body", "启用后,CiCy 启动的 AI 工具(claude / codex 等)访问 AI 厂商(Claude / OpenAI / DeepSeek / Gemini)的 HTTPS 流量将被本地审计解密,数据留本地,随时可关闭。")}
        {!granted && <>
          <br /><span className="mitm-card__note">{t("adminNote", "通过环境变量对 CiCy 启动的 AI 工具生效,不修改系统、无需管理员授权。")}</span>
          <br /><span className="mitm-card__sub">{t("scopeNote", "仅解密上述 AI 厂商域名,其余一切流量不被解密、不被读取。")}</span>
        </>}
      </p>
      {error && <div className="mitm-card__error" data-id="MitmConsentCard-error">{t("errorTitle", "操作失败")}: {error}</div>}
      <div className="mitm-card__actions">
        {granted ? (
          <button data-id="MitmConsentCard-revoke" className="mitm-card__btn mitm-card__btn--ghost"
            disabled={!!busy} onClick={() => { if (window.confirm(t("revokeConfirm", "撤销后将停止解密审计并清除同意标记。确定?"))) disable(); }}>
            {busy === "disable" ? t("processingRevoke", "正在关闭…") : t("revoke", "撤销")}
          </button>
        ) : (
          <button data-id="MitmConsentCard-enable" className="mitm-card__btn"
            disabled={!!busy} onClick={enable}>
            {busy === "enable" ? t("processingEnable", "正在启用…") : partial ? t("retry", "重试") : t("enable", "同意并启用")}
          </button>
        )}
      </div>
    </div>
  );
}

function DockerSetup({ onReady }) {
  const [status, setStatus] = useState(null);  // {platform, installed, imagePresent, running}
  const [phases, setPhases] = useState({});    // key -> { status, message, progress }
  const [busy, setBusy] = useState(false);

  const refresh = useCallback(async () => {
    if (!window.cicy?.docker?.status) return;
    try { setStatus(await window.cicy.docker.status()); } catch {}
  }, []);
  useEffect(() => { refresh(); }, [refresh]);

  // Only relevant on Windows while the local daemon isn't up yet.
  if (!status || status.platform !== "win32" || status.running) return null;

  const run = async () => {
    if (busy) return;
    setBusy(true);
    const unsub = window.cicy.docker.onProgress((ev) => {
      if (ev && ev.phase) setPhases((p) => ({ ...p, [ev.phase]: ev }));
    });
    try {
      const r = await window.cicy.docker.bootstrap();
      if (r?.ok) onReady?.();
    } catch {}
    finally { try { unsub?.(); } catch {} setBusy(false); refresh(); }
  };

  const started = Object.keys(phases).length > 0;

  return (
    <div data-id="DockerSetup" className="docker-setup">
      <div className="docker-setup__head">
        <span className="docker-setup__title">本机团队需要 Docker</span>
        <span className="docker-setup__sub">
          Windows 上 cicy-code 跑在 Docker(Linux 容器)里。一键装好并启动,或先用云端/已添加的团队。
        </span>
      </div>
      <div className="docker-setup__steps">
        {DOCKER_STEPS.map(({ key, label }) => {
          const ph = phases[key];
          const st = ph?.status || "pending";
          return (
            <div key={key} data-id={`DockerSetup-step-${key}`} className={`docker-step is-${st}`}>
              <span className="docker-step__dot" aria-hidden />
              <span className="docker-step__label">{label}</span>
              {ph?.message && <span className="docker-step__msg" title={ph.message}>{ph.message}</span>}
              {st === "running" && typeof ph?.progress === "number" && (
                <span className="docker-step__pct">{ph.progress}%</span>
              )}
            </div>
          );
        })}
      </div>
      <div className="docker-setup__actions">
        <button type="button" data-id="DockerSetup-run" className="btn-primary" disabled={busy} onClick={run}>
          {busy ? tr("docker.working", "处理中…") : started ? tr("docker.retry", "重试") : tr("docker.run", "一键安装并启动")}
        </button>
        <button
          type="button"
          data-id="DockerSetup-manual"
          className="btn-ghost"
          onClick={() => window.cicy?.shell?.openExternal?.("https://www.docker.com/products/docker-desktop/")}
        >
          {tr("docker.manual", "手动装 Docker")}
        </button>
      </div>
    </div>
  );
}

// ── Docker install drawer: streams the Docker-版 cicy-code setup (装 Docker→
// 加载镜像→启动容器→就绪), mirroring the cicy-code 升级 drawer. The bootstrap
// emits {phase,status,message,progress} on 'docker:app-progress'; the card tees
// those here via dockerDrawer.push. Single global instance at shell root.
const dockerDrawerListeners = new Set();
let dockerDrawerLogSeq = 0;
let dockerDrawerState = null; // null = closed
function emitDockerDrawer() { dockerDrawerListeners.forEach((l) => l(dockerDrawerState)); }
const dockerDrawer = {
  open({ onRetry, kind, source, title, sub } = {}) {
    // kind: "install"(默认,安装/升级,带 4 段 stepper)| "open"(打开失败报告,纯日志+hint,无 stepper)
    // source: "op"(默认,某个 run* 函数开的,它自己订阅 push)| "auto"(全局兜底监听开的,
    //   用于程序触发/renderer 重连后还在跑的 bootstrap —— 全局监听负责 push,保证后台不静默)
    // title / sub: 可选,非 Docker 的安装流程(如 DeepSeek Harness 卡)复用同一个抽屉时覆盖标题/副标题。
    dockerDrawerState = { status: "running", phase: "install-docker", kind: kind || "install", source: source || "op", title: title || "", sub: sub || "", logs: [], bars: {}, minimized: false, onRetry: onRetry || null, lastAt: Date.now() };
    emitDockerDrawer();
  },
  minimize() { if (dockerDrawerState) { dockerDrawerState = { ...dockerDrawerState, minimized: true }; emitDockerDrawer(); } },
  restore() { if (dockerDrawerState) { dockerDrawerState = { ...dockerDrawerState, minimized: false }; emitDockerDrawer(); } },
  push(ev = {}) {
    if (!dockerDrawerState) return;
    const phase = ev.phase === "health" ? "container" : (ev.phase || dockerDrawerState.phase);
    const next = { ...dockerDrawerState, phase, lastAt: Date.now() };
    const hasPct = Number.isFinite(ev.progress);
    const isDl = phase === "install-docker" || phase === "image";
    // Any download-related event (running %, skip, done — they carry url/dest)
    // drives a per-phase PROGRESS BAR, not a log line, so the log doesn't
    // scroll-spam (下载不要输出滚动/日志太多).
    if (isDl && (hasPct || ev.dest || ev.url)) {
      const prev = dockerDrawerState.bars?.[phase] || {};
      const progress = hasPct ? ev.progress : (ev.status === "skip" || ev.status === "done") ? 100 : prev.progress;
      next.bars = { ...dockerDrawerState.bars, [phase]: { progress, received: ev.received ?? prev.received, total: ev.total ?? prev.total, url: ev.url || prev.url, dest: ev.dest || prev.dest, label: ev.label || prev.label } };
    }
    // Log only milestone events — never the per-% running download ticks.
    const isRunningTick = ev.status === "running" && hasPct && isDl;
    if (!isRunningTick) {
      // 去重:两条订阅(全局兜底 + 某 run*() 自己的)偶发对同一事件各 push 一次 → 日志重复。
      // 末行 phase/status/message 完全相同则跳过(兜底,不影响正常重复行很少的场景)。
      const last = dockerDrawerState.logs[dockerDrawerState.logs.length - 1];
      const st = ev.status || "running";
      const msg = ev.message || "";
      if (!(last && last.phase === phase && last.status === st && last.message === msg)) {
        const line = { id: ++dockerDrawerLogSeq, t: clockHHMMSS(), phase, status: st, message: msg };
        next.logs = [...dockerDrawerState.logs, line];
      }
    }
    dockerDrawerState = next;
    emitDockerDrawer();
  },
  finish({ ok, message, status } = {}) {
    if (!dockerDrawerState) return;
    // 幂等:已经进了终态就别再 finish(否则 bootstrap emit / runBootstrap 收尾 /
    // checkStatus 自愈 三条路径各写一遍「已就绪」→ 重复日志)。只第一个终态生效。
    if (dockerDrawerState.status !== "running") return;
    // status can be forced (e.g. "reboot" — not a failure, just needs a restart).
    const st = status || (ok ? "done" : "error");
    // On FAILURE keep the phase where it actually broke, so the "!" lands on the
    // failing step (e.g. 启动服务) and earlier steps stay ✓. Only success/reboot
    // advance to the 完成 step — a step literally named "完成" showing 安装失败 is
    // nonsense (bug: "为什么安装失败了,还完成").
    const phase = st === "error" ? dockerDrawerState.phase : "done";
    const line = { id: ++dockerDrawerLogSeq, t: clockHHMMSS(), phase, status: st, message: message || (ok ? "完成" : "失败") };
    // Pop back open on finish so the user sees the result even if minimized.
    dockerDrawerState = { ...dockerDrawerState, status: st, phase, minimized: false, logs: [...dockerDrawerState.logs, line], lastAt: Date.now() };
    emitDockerDrawer();
  },
  close() { dockerDrawerState = null; emitDockerDrawer(); },
};
const DOCKER_PHASES = [["install-docker", "准备环境"], ["image", "下载运行环境"], ["container", "启动服务"], ["done", "完成"]];
const DOCKER_BADGE = { "install-docker": "准备", image: "下载", container: "启动", health: "启动", done: "完成", open: "打开" };
const DOCKER_DL_LABEL = { "install-docker": "Docker Desktop", image: "基础镜像" };
function fmtBytes(n) {
  if (!Number.isFinite(n)) return "?";
  if (n < 1024) return n + " B";
  if (n < 1048576) return (n / 1024).toFixed(0) + " KB";
  if (n < 1073741824) return (n / 1048576).toFixed(1) + " MB";
  return (n / 1073741824).toFixed(2) + " GB";
}
// One fixed (non-scrolling) progress bar per download (Docker Desktop / image),
// showing the source URL + % + bytes (下载做进度条、显示地址、不要滚动).
function DownloadBar({ phaseKey, bar }) {
  const pct = Number.isFinite(bar?.progress) ? Math.max(0, Math.min(100, bar.progress)) : 0;
  const done = pct >= 100;
  return (
    <div className="dlbar" data-id={`DockerDrawer-dlbar-${phaseKey}`}>
      <div className="dlbar__head">
        <span className="dlbar__name">{bar?.label || DOCKER_DL_LABEL[phaseKey] || phaseKey}</span>
        <span className="dlbar__pct">{pct}%{bar?.total ? ` · ${fmtBytes(bar.received)} / ${fmtBytes(bar.total)}` : ""}</span>
      </div>
      <div className="dlbar__track"><div className={`dlbar__fill${done ? " is-done" : ""}`} style={{ width: `${pct}%` }} /></div>
      {bar?.url && <div className="dlbar__url" title={bar.url}><span className="dlbar__urlk">源</span> {bar.url}</div>}
      {bar?.dest && <div className="dlbar__url" title={bar.dest}><span className="dlbar__urlk">存</span> {bar.dest}</div>}
    </div>
  );
}
function DockerInstallDrawerHost() {
  const [st, setSt] = useState(dockerDrawerState);
  useEffect(() => { dockerDrawerListeners.add(setSt); return () => { dockerDrawerListeners.delete(setSt); }; }, []);
  const logRef = useRef(null);
  useEffect(() => { const el = logRef.current; if (el) el.scrollTop = el.scrollHeight; }, [st?.logs?.length]);
  if (!st) return null;
  const running = st.status === "running";
  const isOpen = st.kind === "open"; // 打开失败报告:无安装 stepper、标题/失败标签用「打开」
  const phaseIdx = DOCKER_PHASES.findIndex(([k]) => k === st.phase);
  const dlBars = isOpen ? [] : ["install-docker", "image"].filter((k) => st.bars?.[k]);
  const drawerTitle = st.title || (isOpen ? tr("docker.openTitle", "打开 Docker 团队") : tr("docker.setupTitle", "安装 Docker cicy-code"));
  const drawerSub = st.sub || "127.0.0.1:8008";
  // Minimized → a floating restore chip (op keeps running in the background).
  if (st.minimized) {
    const pcts = dlBars.map((k) => st.bars[k]?.progress).filter(Number.isFinite);
    const overall = pcts.length ? Math.round(pcts.reduce((a, b) => a + b, 0) / pcts.length) : null;
    return (
      <button type="button" className={`drawer-min drawer-min--${st.status}`} data-id="DockerDrawer-restore" onClick={() => dockerDrawer.restore()}>
        <span className="drawer-min__spark">{running ? <Spinner /> : st.status === "done" ? "✓" : st.status === "reboot" ? "⟳" : "!"}</span>
        <span className="drawer-min__label">{drawerTitle}{overall != null ? ` · ${overall}%` : ""}</span>
      </button>
    );
  }
  return (
    <div className="drawer-scrim" data-id="DockerDrawer-scrim" onClick={() => running ? dockerDrawer.minimize() : dockerDrawer.close()}>
      <div className="drawer" data-id="DockerDrawer" data-status={st.status} onClick={(e) => e.stopPropagation()}>
        <div className="drawer__head">
          <div className="drawer__title">
            <span className={`drawer__spark drawer__spark--${st.status}`}>
              {running ? <Spinner /> : st.status === "done" ? "✓" : st.status === "reboot" ? "⟳" : "!"}
            </span>
            <div>
              <div className="drawer__h">{drawerTitle}</div>
              <div className="drawer__sub">{drawerSub}</div>
            </div>
          </div>
          <div className="drawer__headbtns">
            <button type="button" className="drawer__x" data-id="DockerDrawer-min" title={tr("common.minimize", "最小化")} onClick={() => dockerDrawer.minimize()} aria-label="minimize">‒</button>
          </div>
        </div>

        {!isOpen && <div className="drawer__steps" data-id="DockerDrawer-steps">
          {DOCKER_PHASES.map(([k, label], i) => {
            const done = st.status === "done" || (phaseIdx >= 0 && i < phaseIdx);
            const active = i === phaseIdx && running;
            const err = st.status === "error" && i === phaseIdx;
            return (
              <div key={k} className={`drawer__step${active ? " is-active" : ""}${done ? " is-done" : ""}${err ? " is-error" : ""}`}>
                <span className="drawer__step-dot">{done ? "✓" : err ? "!" : i + 1}</span>
                <span className="drawer__step-label">{label}</span>
                {i < DOCKER_PHASES.length - 1 && <span className="drawer__step-bar" />}
              </div>
            );
          })}
        </div>}

        {dlBars.length > 0 && (
          <div className="drawer__dlbars" data-id="DockerDrawer-dlbars">
            {dlBars.map((k) => <DownloadBar key={k} phaseKey={k} bar={st.bars[k]} />)}
          </div>
        )}

        {/* Prominent "what's happening NOW" line — so a download bar at 100% is
            never mistaken for the whole flow being done (bug). */}
        {running && st.logs.length > 0 && (
          <div className="drawer__now" data-id="DockerDrawer-now">
            <Spinner /><span>{st.logs[st.logs.length - 1].message}</span>
          </div>
        )}

        <div className="drawer__log drawer__log--scroll" data-id="DockerDrawer-log" ref={logRef}>
          {st.logs.length === 0
            ? <div className="drawer__log-empty">{tr("docker.preparing", "准备中…")}</div>
            : st.logs.map((l) => (
              <div key={l.id} className="drawer__line" data-status={l.status}>
                <span className="drawer__t">{l.t}</span>
                <span className={`drawer__badge drawer__badge--${l.phase}`}>{tr(`dockerBadge.${l.phase}`, DOCKER_BADGE[l.phase] || l.phase)}</span>
                <span className="drawer__linemsg">{l.message}</span>
              </div>
            ))}
        </div>

        <div className="drawer__foot">
          {running ? (
            <>
              <span className="drawer__foot-status">{isOpen ? tr("docker.opening", "打开中…") : tr("docker.installing2", "安装进行中…")}</span>
            </>
          ) : st.status === "reboot" ? (
            <>
              <span className="drawer__foot-status is-reboot">{tr("docker.rebootAuto", "需重启 Windows（90 秒后自动重启，登录后自动继续）")}</span>
              <button type="button" className="drawer__btn is-accent" data-id="DockerDrawer-reboot-now" onClick={() => window.cicy?.docker?.rebootNow?.()}>{tr("docker.rebootNow", "立即重启")}</button>
              <button type="button" className="drawer__btn" data-id="DockerDrawer-reboot-cancel" onClick={() => { window.cicy?.docker?.rebootCancel?.(); dockerDrawer.close(); }}>{tr("docker.rebootCancel", "取消自动重启")}</button>
            </>
          ) : st.status === "error" ? (
            <>
              <span className="drawer__foot-status is-error">{isOpen ? tr("docker.openFailed", "打开失败") : tr("docker.failed", "安装失败")}</span>
              {st.onRetry && <button type="button" className="drawer__btn is-accent" data-id="DockerDrawer-retry" onClick={() => st.onRetry()}>{tr("common.retry", "重试")}</button>}
              <button type="button" className="drawer__btn" data-id="DockerDrawer-dismiss" onClick={() => dockerDrawer.close()}>{tr("common.close", "关闭")}</button>
            </>
          ) : (
            <>
              <span className="drawer__foot-status is-done">{tr("docker.ready", "已就绪")}</span>
              <button type="button" className="drawer__btn is-accent" data-id="DockerDrawer-finish" onClick={() => dockerDrawer.close()}>{tr("common.done", "完成")}</button>
            </>
          )}
        </div>
      </div>
    </div>
  );
}

// ---------------------------------------------------------------------------
// 车队 dsh —— 每台矩阵机一张卡,点开直接进那台机器的 DeepSeek Harness。
//
// dsh 只监听那台机 Windows 侧的 127.0.0.1:3080,外面进不去。反代不走 cloudflared、
// 不走 frp、也不碰机器上的 WSL:cicy-fleet hub 上有一条专用中继(见
// ~/cicy-fleet/dsh-proxy.js + px-agent.js),机器的 Electron 主进程连上来,
// <机器>-dsh.cicy-ai.com 的请求就经这条中继打到它本机的 3080(HTTP 流式 + WS 都转)。
//
// 这里只做两件事:拉一份机器清单(/api/dsh-list,不含任何 token),和把「打开」指到
// /_cicy/enter —— hub 收到后现场向那台机器要 dsh 的 token 再 302 过去,
// 所以 token 不会出现在首页里。
const DSH_FLEET_API = "./api/dsh-list";

function useDshFleet() {
  const [list, setList] = useState(null);
  const [busy, setBusy] = useState(false);
  const refresh = useCallback(async () => {
    setBusy(true);
    try {
      const r = await fetch(DSH_FLEET_API, { cache: "no-store" });
      const j = await r.json();
      setList(Array.isArray(j && j.machines) ? j.machines : []);
    } catch { setList([]); }
    finally { setBusy(false); }
  }, []);
  // 一个 hub 上的接口(带 20s 缓存),不是对 19 台机器扇出,所以开机就拉、慢速轮询即可。
  useEffect(() => {
    refresh();
    const t = setInterval(refresh, 30000);
    return () => clearInterval(t);
  }, [refresh]);
  return { list, busy, refresh };
}

function DshFleetCard({ m }) {
  const [busy, setBusy] = useState(false);
  const open = async () => {
    if (busy || !m.ready) return;
    setBusy(true);
    try {
      // 先要票再开。没票不要直接打 /_cicy/enter —— 那条路正在下线。
      const ticket = typeof window.__cicyDshTicket === "function" ? await window.__cicyDshTicket(m.name) : "";
      if (!ticket) {
        toast.show({ id: "dsh-ticket", status: "error", ttl: 6000,
          message: tr("dshFleet.noTicket", "拿不到进入凭据 —— 这台 Desktop 还没连上车队或未登录,稍等几秒再试。") });
        return;
      }
      const url = `https://${m.host}/_cicy/enter?t=${encodeURIComponent(ticket)}`;
      if (window.cicy?.tabs?.openIn) await window.cicy.tabs.openIn(0, url, m.name + " · dsh");
      else window.cicy?.shell?.openExternal?.(url);
    } catch { /* 票据失败不再退回无凭据打开 */ }
    finally { setBusy(false); }
  };
  return (
    <div data-id="DshFleetCard" className={`bcard bcard--custom${m.ready ? " bcard--online" : ""}`} title={m.host}>
      <div className="bcard__accent" />
      <div className="bcard__top">
        <div className="bcard__pill">
          <span className="bcard__dot" data-tone={m.ready ? "ok" : "off"} />
          <LaptopIcon />
        </div>
      </div>
      <div className="bcard__body">
        <div style={{ height: 28, display: "flex", alignItems: "center", gap: 8 }}>
          <TeamAvatar size={24} name={m.name} teamId={"desktop-" + m.name} />
          <h3 className="bcard__name" style={{ flex: 1, minWidth: 0, margin: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{m.name}</h3>
        </div>
        <div className="bcard__meta">
          <span className="bcard__chip" data-id="DshFleetCard-kind">dsh</span>
        </div>
        <div data-id="DshFleetCard-host" title={m.host}
          style={{ marginTop: 6, fontSize: 11, color: "#8b949e", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
          {m.ready ? m.host : (m.up ? tr("dshFleet.noToken", "dsh 未就绪") : tr("dshFleet.noRelay", "中继未连接"))}
        </div>
      </div>
      <button type="button" className="bcard__cta" data-id="DshFleetCard-open" disabled={busy || !m.ready} onClick={open}>
        {busy ? <Spinner /> : <ArrowIcon />}
        <span>{m.ready ? tr("cicyHub.open", "打开") : tr("dshFleet.unready", "未就绪")}</span>
      </button>
    </div>
  );
}

// ---------------------------------------------------------------------------
// DeepSeek Harness (dsh) card —— 首页第二块:开源 Agent 框架,模型可换,和 cicy-code 卡并列。
//
// 全部只靠 homepage 完成(不改 desktop):走首页 preload 暴露的无门禁 `electronRPC`
// (exec_shell / exec_node_file),在本机 npm 全局安装锁定版本的 dsh,再以 detached 子进程
// 拉起 `dsh web --no-open --port 3080`。首页每次打开(= desktop 启动)时:
//   未装 → 自动安装 + 启动(抽屉里看进度;失败后 6 小时内不再自动重试,可手动点卡片重试)
//   已装未跑 → 静默启动
//   在跑 → 「打开」
// dsh web 是 token 门禁的:进程启动时把 `?token=…` 打到 stdout,我们把 stdout 落到
// ~/cicy-ai/logs/dsh.log,「打开」时从日志取最新 token 拼 URL(换到 cookie 后 URL 自动变干净)。
// 版本必须锁死:dsh 还在 developer preview,官方明说会有破坏性变更。
const DSH_PKG = "@deepseek-ai/dsh";
const DSH_VERSION = "0.1.2-rc.1";
const DSH_PORT = 3080;
const DSH_BLUE = "#4d6bfe";
const DSH_NODE_VERSION = "v24.19.0";
const DSH_NODE_ZIP_BYTES = 37304352;        // node-v24.19.0-win-x64.zip 大小,只用来算下载进度条        // Windows 没装 Node 时自动下载的便携版(zip 解压到 %LOCALAPPDATA%\cicy-node,免管理员)
const DSH_AUTO_KEY = "dsh.auto";            // localStorage:"off" = 关闭开机自装/自启
const DSH_FAIL_BACKOFF_MS = 6 * 60 * 60 * 1000;
// 自动安装失败的退避标记按首页发布戳分桶:发新版首页后自动再试一次,不用干等 6 小时。
const dshFailKey = () => `dsh.installFailAt.${typeof BUILD_STAMP === "string" ? BUILD_STAMP : "dev"}`;

// ---- 第 0 步:找 Node(exec_shell,不依赖 PATH)。矩阵机的 Electron 进程 PATH 里没有 node,
// 甚至机器上根本没装 Node,所以一切都用绝对路径,Windows 缺 Node 就下载便携版。
// PowerShell 用 -EncodedCommand 传脚本,避免 cmd 引号地狱;脚本里不能出现 "${"(JS 模板会吃掉)。
// 找 Node:纯 cmd 一行链(不再为了找 node 起 PowerShell,矩阵机 PowerShell 冷启动能到 60s)。
// 候选按顺序试 --version,第一个能跑的输出 NODE=路径;版本号由 JS 侧校验(>=22)。
const DSH_FIND_NODE_CMD = [
  `"%LOCALAPPDATA%\\cicy-node\\node.exe"`,
  `"%ProgramFiles%\\nodejs\\node.exe"`,
  `"%LOCALAPPDATA%\\Programs\\nodejs\\node.exe"`,
  `"%APPDATA%\\nvm\\current\\node.exe"`,
].map((q) => `(${q} --version 2>nul && echo NODE=${q.slice(1, -1)})`).join(" || ") + " & echo HOME=%USERPROFILE%";

// Windows 缺 Node 时下载便携版(zip 解压到 %LOCALAPPDATA%\cicy-node,免管理员)。
// 下载/解压临时目录也放 %LOCALAPPDATA%(不用 %TEMP%:用户名带点的机器 %TEMP% 是 8.3 短路径 XS4638~1.COM,PowerShell 的 Move-Item/Remove-Item 会报"对象不存在")。
// 实测矩阵机:nodejs.org 2.5~6MB/s,npmmirror 只有 100KB~1MB/s,而且哪个快因机器/时段而异,
// 所以先并行各下 5 秒测速再选源;下载用 curl.exe(带断速放弃:30s 内低于 80KB/s 就换下一个源),
// 解压用 tar.exe(比 Expand-Archive 快很多)。上次下到一半/解压好没搬完的都复用。
// 脚本写成 ~/cicy-ai/db/dsh-node.ps1 再 -File 跑(超过 cmd 8K 上限,不能 -EncodedCommand);脚本里不能出现 "${"(JS 模板会吃掉),也不要写非 ASCII 字符(PS 5.1 无 BOM 按 GBK 读会吞行)。
function dshInstallNodePs1() {
  return `$ErrorActionPreference = 'SilentlyContinue'; $ProgressPreference = 'SilentlyContinue'
$ver = '${DSH_NODE_VERSION}'; $name = "node-$ver-win-x64"
$dir = "$env:LOCALAPPDATA\\cicy-node"; $tmp = "$env:LOCALAPPDATA\\cicy-node-dl"; $zip = "$tmp\\$name.zip"; $ex = "$tmp\\$name"; $lock = "$tmp\\install.lock"
$curl = "$env:SystemRoot\\System32\\curl.exe"; $tar = "$env:SystemRoot\\System32\\tar.exe"
New-Item -ItemType Directory -Force -Path $tmp | Out-Null
function Good($p) { if (-not (Test-Path "$p\\node.exe")) { return $false }; $v = & "$p\\node.exe" --version 2>$null; return ($v -match '^v(\\d+)\\.' -and [int]$matches[1] -ge 22) }
# file ops retry: Defender / 360 scan a fresh 37MB zip and hold it for seconds, so tar/move/delete fail with sharing violations
function Retry($n, $sb) { for ($k = 0; $k -lt $n; $k++) { $ok = $false; try { $ok = [bool](& $sb) } catch { $ok = $false }; if ($ok) { return $true }; Start-Sleep -Seconds 2 }; return $false }
function ZipOk($z) { if (-not (Test-Path $z)) { return $false }; if ((Get-Item $z).Length -lt 30000000) { return $false }; if (Test-Path $tar) { return (Retry 6 { & $tar -tf $z 2>$null | Out-Null; $LASTEXITCODE -eq 0 }) }; return $true }
function LockAlive { if (-not (Test-Path $lock)) { return $false }; $id = 0; try { $id = [int](Get-Content $lock -Raw) } catch { return $false }; if ($id -le 0) { return $false }; return ($null -ne (Get-Process -Id $id -ErrorAction SilentlyContinue)) }
function Main {
  $t0 = Get-Date
  if (Good $dir) { return "NODE=$dir\\node.exe" }
  if (-not (Good $ex)) {
    $urls = @("https://nodejs.org/dist/$ver/$name.zip", "https://cdn.npmmirror.com/binaries/node/$ver/$name.zip", "https://npmmirror.com/mirrors/node/$ver/$name.zip")
    $have = ZipOk $zip
    if (-not $have) {
      Remove-Item $zip -Force
      if (Test-Path $curl) {
        $procs = @(); $i = 0
        foreach ($u in $urls[0..1]) { $i++; $f = "$tmp\\spd$i.txt"; Remove-Item $f -Force; $procs += ,@($u, $f, (Start-Process -FilePath $curl -ArgumentList @('-s','-L','-o','NUL','-w','%{size_download}','--max-time','5',$u) -RedirectStandardOutput $f -WindowStyle Hidden -PassThru)) }
        Start-Sleep -Seconds 6
        $score = @{}
        foreach ($p in $procs) { if (-not $p[2].HasExited) { $p[2].Kill() }; $n = 0; try { $n = [int64]((Get-Content $p[1] -Raw) -replace '[^0-9]', '') } catch { }; $score[$p[0]] = $n; Write-Output "SPD=$n $($p[0])" }
        $urls = @($urls | Sort-Object { $s = 0; if ($score.ContainsKey($_)) { $s = $score[$_] }; -$s })
        foreach ($u in $urls) {
          Write-Output "SRC=$u"
          # resume .part with -C - (same zip on every mirror); give up a source once it drops under 80KB/s for 30s
          & $curl -L -s -S -o "$zip.part" -C - --speed-limit 80000 --speed-time 30 --max-time 900 $u 2>$null
          $code = $LASTEXITCODE; $n = 0; if (Test-Path "$zip.part") { $n = (Get-Item "$zip.part").Length }
          Write-Output "CURL=$code bytes=$n"
          if ($code -eq 0 -and (ZipOk "$zip.part")) { Retry 8 { Move-Item "$zip.part" $zip -Force; (Test-Path $zip) -and -not (Test-Path "$zip.part") } | Out-Null; if (ZipOk $zip) { $have = $true; break } }
          if ($code -eq 0 -or $n -gt 40000000) { Remove-Item "$zip.part" -Force }
        }
      }
      if (-not $have) {
        [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
        foreach ($u in $urls) { try { Remove-Item $zip -Force; Write-Output "SRC=$u"; Write-Output "IWR=$u"; Invoke-WebRequest -Uri $u -OutFile $zip -UseBasicParsing -TimeoutSec 300; if (ZipOk $zip) { $have = $true; break } } catch { } }
      }
      if (-not $have) { return 'ERR=download' }
    }
    Retry 5 { Remove-Item $ex -Recurse -Force; -not (Test-Path $ex) } | Out-Null
    if (Test-Path $tar) { Retry 3 { & $tar -xf $zip -C $tmp 2>$null; Good $ex } | Out-Null }
    if (-not (Good $ex)) { Remove-Item $ex -Recurse -Force; Expand-Archive -Path $zip -DestinationPath $tmp -Force }
    if (-not (Good $ex)) { return 'ERR=extract' }
  }
  if (Test-Path $dir) { Retry 8 { Remove-Item $dir -Recurse -Force; -not (Test-Path $dir) } | Out-Null }
  Retry 10 { Move-Item $ex $dir; Good $dir } | Out-Null
  if (-not (Good $dir)) { Copy-Item $ex $dir -Recurse -Force }
  Retry 5 { Remove-Item $zip -Force; Remove-Item "$zip.part" -Force; Remove-Item "$tmp\\spd*.txt" -Force; Remove-Item $ex -Recurse -Force; -not (Test-Path $ex) } | Out-Null
  Write-Output ("SECS=" + [int]((Get-Date) - $t0).TotalSeconds)
  if (Good $dir) { return "NODE=$dir\\node.exe" } else { return 'ERR=move' }
}
# install lock: a page reload / second window re-triggers setup; two extractors would trample each other, so wait for the live one
if (LockAlive) { for ($i = 0; $i -lt 450; $i++) { Start-Sleep -Seconds 2; if (Good $dir) { break }; if (-not (LockAlive)) { break } } }
if (Good $dir) { Write-Output "NODE=$dir\\node.exe"; exit 0 }
Set-Content -Path $lock -Value $PID
$r = @(Main)
Remove-Item $lock -Force
$r | ForEach-Object { Write-Output $_ }
if ($r.Count -gt 0 -and "$($r[-1])" -like 'NODE=*') { exit 0 } else { exit 1 }
`;
}
const DSH_NODE_SH = `for p in /usr/local/bin/node /opt/homebrew/bin/node /usr/bin/node "$HOME"/.nvm/versions/node/*/bin/node "$HOME"/.volta/bin/node; do if [ -x "$p" ]; then v=$("$p" --version 2>/dev/null); case "$v" in v2[2-9].*|v[3-9][0-9].*) echo "NODE=$p"; break;; esac; fi; done; echo "HOME=$HOME"`;

// 在本机 node 里跑的控制脚本,写到 ~/cicy-ai/db/dsh-ctl.js,用绝对路径的 node 执行:
//   node dsh-ctl.js <status|install|start|stop|open> <npm 绝对路径>
// 所有平台同一份,只打一行 JSON 到 stdout。
const DSH_CTL_SCRIPT = String.raw`
const fs = require("fs"), path = require("path"), os = require("os"), cp = require("child_process");
const OP = process.argv[2] || "status", NPM = process.argv[3] || "npm";
const PORT = ${DSH_PORT}, PKG = "${DSH_PKG}", VER = "${DSH_VERSION}";
const home = os.homedir();
const dbDir = path.join(home, "cicy-ai", "db"), logDir = path.join(home, "cicy-ai", "logs");
for (const d of [dbDir, logDir]) { try { fs.mkdirSync(d, { recursive: true }); } catch {} }
const logFile = path.join(logDir, "dsh.log"), errFile = path.join(logDir, "dsh.err.log"), pidFile = path.join(dbDir, "dsh.pid"), rootCache = path.join(dbDir, "dsh-npm-root.txt");
const lockFile = path.join(dbDir, "dsh-install.lock"), okMarker = path.join(dbDir, "dsh-ok-" + VER + ".txt");
// 车队级启动环境(hub 下发,DshCard 写的 dsh-env.json):并进 dsh 子进程 env。启动环境在 dsh 里优先级最高且只读。
const envFile = path.join(dbDir, "dsh-env.json"), envApplied = path.join(dbDir, "dsh-env.applied");
function fleetEnv() { try { const j = JSON.parse(fs.readFileSync(envFile, "utf8")); const src = (j && j.env) || {}; const out = {}; for (const [k, v] of Object.entries(src)) { if (/^[A-Z][A-Z0-9_]*$/.test(k) && typeof v === "string" && v) out[k] = v; } return out; } catch { return {}; } }
function envHash() { const e = fleetEnv(); const ks = Object.keys(e).sort(); return ks.length ? require("crypto").createHash("sha1").update(JSON.stringify(ks.map((k) => [k, e[k]]))).digest("hex").slice(0, 12) : ""; }
function appliedHash() { try { return fs.readFileSync(envApplied, "utf8").trim(); } catch { return ""; } }
// 默认工作区:hub 下发 [{path,title}],路径支持 ~ 和 %VAR%。dsh 的 workspace.json 是 single 布局、内存为准,
// 所以只在 dsh 没跑的时候(start 之前)种记录;status 只报哪些还缺,让 DshCard 决定重启。
const wsFile = path.join(home, ".dsh", "storages", "workspace.json");
function expandPath(p) { let s = String(p || "").trim(); if (!s) return ""; if (s === "~" || s.startsWith("~/") || s.startsWith("~\\")) s = home + s.slice(1); s = s.replace(/%([A-Za-z_][A-Za-z0-9_]*)%/g, (m, k) => process.env[k] || m).replace(/\$([A-Z_][A-Z0-9_]*)/g, (m, k) => process.env[k] || m); return path.resolve(s); }
function fleetWorkspaces() { try { const j = JSON.parse(fs.readFileSync(envFile, "utf8")); return (Array.isArray(j.workspaces) ? j.workspaces : []).map((w) => ({ path: expandPath(typeof w === "string" ? w : w && w.path), title: (w && w.title) || "" })).filter((w) => w.path); } catch { return []; } }
function canon(p) { try { p = fs.realpathSync.native(p); } catch {} return process.platform === "win32" ? p.toLowerCase() : p; }
function readWs() { try { return JSON.parse(fs.readFileSync(wsFile, "utf8")); } catch { return null; } }
function wsMissing() { const cfg = fleetWorkspaces(); if (!cfg.length) return []; const j = readWs(); const have = new Set(Object.values((j && j.tables && j.tables.workspaces) || {}).map((r) => canon(r.path))); return cfg.filter((w) => !have.has(canon(w.path))).map((w) => w.path); }
function seedWorkspaces() {
  const cfg = fleetWorkspaces(); if (!cfg.length) return { seeded: [] };
  let j = readWs(); if (!j || !j.tables || !j.global) j = { unit: { name: "workspace", version: 2 }, global: { initialized: true, workspaceIds: [], archivedSessionIds: [] }, tables: { workspaces: {} } };
  const have = new Map(Object.values(j.tables.workspaces).map((r) => [canon(r.path), r])); const seeded = [];
  for (const w of cfg) {
    try { fs.mkdirSync(w.path, { recursive: true }); } catch {}
    let real = w.path; try { real = fs.realpathSync.native(w.path); } catch { continue; }
    if (have.has(canon(real))) continue;
    const id = require("crypto").randomUUID(), now = new Date().toISOString();
    j.tables.workspaces[id] = { path: real, title: w.title || path.basename(real) || real, sessionIds: [], createdAt: now, updatedAt: now };
    j.global.workspaceIds.push(id); j.global.initialized = true; have.set(canon(real), j.tables.workspaces[id]); seeded.push(real);
  }
  if (seeded.length) { try { fs.mkdirSync(path.dirname(wsFile), { recursive: true }); const tmp = wsFile + ".tmp-" + process.pid; fs.writeFileSync(tmp, JSON.stringify(j, null, 2)); fs.renameSync(tmp, wsFile); } catch (e) { return { seeded: [], error: String(e.message) }; } }
  return { seeded };
}
// 子进程 PATH:node 所在目录 + System32(矩阵机 Electron 的 PATH 里没有 node)
process.env.PATH = [path.dirname(process.execPath), process.platform === "win32" ? path.join(process.env.SystemRoot || "C:\\Windows", "System32") : "/usr/local/bin", process.env.PATH || ""].join(path.delimiter);
const out = (o) => { process.stdout.write(JSON.stringify(o)); };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const q = (s) => '"' + s + '"';
function run(cmd, opts) { return cp.execSync(cmd, Object.assign({ encoding: "utf8", windowsHide: true, timeout: 60000, stdio: ["ignore", "pipe", "pipe"], maxBuffer: 10 * 1024 * 1024 }, opts || {})); }
function npmRoot() { try { return run(q(NPM) + " root -g").trim().split(/\r?\n/).pop(); } catch { return ""; } }
// 选 registry:两个源并行各拉 5 秒同一个大 tarball,谁字节多先用谁。矩阵机实测 npmjs 2.5~7MB/s、
// npmmirror 只有 13~36KB/s(装 200MB 要几十分钟,必超时);但换到国内机器就反过来,所以每次装前实测。
const REGS = ["https://registry.npmjs.org", "https://registry.npmmirror.com"];
const regFile = path.join(dbDir, "dsh-registry.txt");
async function pickRegistry() {
  const meas = await Promise.all(REGS.map(async (r) => {
    let n = 0;
    try { const res = await fetch(r + "/node-pty/-/node-pty-1.1.0.tgz", { signal: AbortSignal.timeout(5000) }); const rd = res.body.getReader(); for (;;) { const { done, value } = await rd.read(); if (done) break; n += value.length; } } catch {}
    return n;
  }));
  const order = REGS.map((r, i) => [r, meas[i]]).sort((a, b) => b[1] - a[1]);
  try { fs.writeFileSync(regFile, order[0][0]); } catch {}
  return { order: order.map((x) => x[0]), speed: Object.fromEntries(order.map(([r, n]) => [r, Math.round(n / 5 / 1024) + "KB/s"])) };
}
function savedRegistry() { try { return fs.readFileSync(regFile, "utf8").trim(); } catch { return ""; } }
function pidAlive(n) { if (!n) return false; try { process.kill(n, 0); return true; } catch { return false; } }
function readPidFile(f) { try { const n = Number(fs.readFileSync(f, "utf8").trim()); return n > 0 ? n : 0; } catch { return 0; } }
// 已装 = 目录在 + bin 在 + 真能跑(--version 对得上;结果记 marker,以后不再每次 spawn)。
// 并发安装/下到一半会留下缺文件的目录,只看 package.json 会把坏包当好包(Mac 上实测)。
function pkgAt(root) {
  if (!root) return null;
  const dir = path.join(root, "@deepseek-ai", "dsh"), bin = path.join(dir, "lib", "bin.js");
  let v = ""; try { v = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8")).version; } catch { return null; }
  if (!fs.existsSync(bin)) return { root, dir, bin, version: v, broken: true };
  if (v === VER && fs.existsSync(okMarker)) return { root, dir, bin, version: v };
  try {
    const got = run(q(process.execPath) + " " + q(bin) + " --version", { timeout: 90000 }).trim();
    if (got && got.split(/\r?\n/).pop().trim() === v) { if (v === VER) { try { fs.writeFileSync(okMarker, String(Date.now())); } catch {} } return { root, dir, bin, version: v }; }
  } catch {}
  return { root, dir, bin, version: v, broken: true };
}
function pkgInfo() {
  let cached = ""; try { cached = fs.readFileSync(rootCache, "utf8").trim(); } catch {}
  let p = pkgAt(cached);
  if (!p) { const root = npmRoot(); p = pkgAt(root); if (root) { try { fs.writeFileSync(rootCache, root); } catch {} } }
  return p;
}
// 只读日志尾部(64KB):status() 每 20s 跑一次,dsh web 每个请求都写日志,文件只增不减,
// 全量 readFileSync 会把主进程卡住(2026-09-13 全队失联的一个来源)。token 只在最近的启动行里。
function tailOf(file, max) { try { const st = fs.statSync(file); const len = Math.min(st.size, max); if (!len) return ""; const fd = fs.openSync(file, "r"); try { const b = Buffer.alloc(len); fs.readSync(fd, b, 0, len, st.size - len); return b.toString("utf8"); } finally { fs.closeSync(fd); } } catch { return ""; } }
function lastToken() { try { const s = tailOf(logFile, 64 * 1024); const all = [...s.matchAll(/[?&]token=([A-Za-z0-9_-]+)/g)]; return all.length ? all[all.length - 1][1] : ""; } catch { return ""; } }
// 端口探测:up = 有东西在听;dsh = 确认是 dsh web(未登录 401 + "dsh web authentication required",登录后 index 带 __DSH_BOOT__)
async function probe() {
  try {
    const r = await fetch("http://127.0.0.1:" + PORT + "/", { signal: AbortSignal.timeout(2500), redirect: "manual" });
    let body = ""; try { body = (await r.text()).slice(0, 4000); } catch {}
    const dsh = /dsh web/i.test(body) || /__DSH_BOOT__/.test(body) || /dsh-auth-/.test(r.headers.get("set-cookie") || "");
    return { up: true, dsh };
  } catch { return { up: false, dsh: false }; }
}
async function httpUp() { return (await probe()).dsh; }
function installing() { return pidAlive(readPidFile(lockFile)); }
async function status() {
  const p = pkgInfo(); const pid = readPidFile(pidFile); const pr = await probe();
  const eh = envHash(), ea = appliedHash(), wm = wsMissing();
  return { ok: true, installed: !!p && !p.broken, broken: !!(p && p.broken), version: p ? p.version : null, installing: installing(), running: pr.dsh, occupied: pr.up && !pr.dsh, pid, pidAlive: pidAlive(pid), hasToken: !!lastToken(), node: process.version, logFile, envKeys: Object.keys(fleetEnv()), envHash: eh, envStale: pr.dsh && ea !== eh, wsMissing: wm, wsStale: pr.dsh && wm.length > 0 };
}
async function install() {
  // 安装锁:首页刷新/多开会再次触发安装,两个 npm -g 同时写全局目录会把包写坏。有锁就等它完。
  if (installing()) {
    for (let i = 0; i < 600 && installing(); i++) await sleep(2000);
    const p = pkgInfo(); return p && !p.broken ? { ok: true, version: p.version, waited: true } : { ok: false, error: "install_failed_elsewhere" };
  }
  try { fs.writeFileSync(lockFile, String(process.pid)); } catch {}
  try {
    try { fs.unlinkSync(okMarker); } catch {}
    const root = npmRoot();
    if (!root) return { ok: false, error: "npm_unavailable", tail: NPM };
    const scope = path.join(root, "@deepseek-ai");
    // 清掉坏包和 npm 中断留下的 .dsh-xxxx 临时目录,让 npm 干净地重装
    try { for (const e of fs.readdirSync(scope)) { if (e === "dsh" || /^\.dsh-/.test(e)) fs.rmSync(path.join(scope, e), { recursive: true, force: true }); } } catch {}
    // 先实测哪个 registry 快再装(不再固定先走 npmmirror:在这批矩阵机上它只有几十 KB/s,200MB 必超时)。
    const regs = await pickRegistry();
    let outp = "", lastErr = "", used = "", t0 = Date.now();
    for (const reg of regs.order) {
      try { outp = run(q(NPM) + " i -g " + PKG + "@" + VER + " --no-audit --no-fund --no-progress --loglevel=error --fetch-timeout=90000 --fetch-retries=2 --registry=" + reg, { timeout: 12 * 60 * 1000 }); lastErr = ""; used = reg; break; }
      catch (e) { lastErr = String((e.stderr || "") + (e.stdout || "") + (e.message || "")).trim().slice(-600); }
    }
    if (lastErr) return { ok: false, error: "npm_install_failed", tail: lastErr, speed: regs.speed };
    try { fs.writeFileSync(rootCache, root); } catch {}
    const p = pkgAt(root);
    if (!p || p.broken) return { ok: false, error: "install_incomplete", tail: (outp || "").slice(-300) };
    return { ok: true, version: p.version, registry: used, speed: regs.speed, secs: Math.round((Date.now() - t0) / 1000) };
  } finally { try { fs.unlinkSync(lockFile); } catch {} }
}
async function start() {
  const p = pkgInfo(); if (!p || p.broken) return { ok: false, error: "not_installed" };
  const pr = await probe();
  if (pr.dsh) return { ok: true, already: true, token: lastToken() };
  if (pr.up) return { ok: false, error: "port_in_use", tail: "127.0.0.1:" + PORT + " is used by another program" };
  const seeded = seedWorkspaces();   // dsh 此刻没在跑,可以安全改 workspace.json
  const env = Object.assign({}, process.env, { NO_COLOR: "1" }, savedRegistry() ? { npm_config_registry: savedRegistry() } : {}, fleetEnv());   // dsh 首次运行会用 pnpm 自举 ~/.dsh/profiles,沿用实测最快的源;fleetEnv = hub 下发的 API Key 等
  let pid = 0;
  if (process.platform === "win32") {
    // ⚠️ 别用 spawn(detached:true):Windows 上 detached = DETACHED_PROCESS,dsh 会**一个控制台都没有**。
    // 之后 dsh 的 pwsh 工具每执行一条命令,系统就得给那条命令新建一个控制台 —— 那就是屏幕上
    // 不停闪的黑窗口(实测一个会话 76 次 pwsh 调用 = 闪 76 次)。
    // 正解:用 Start-Process -WindowStyle Hidden 起 node,让 dsh 自己拥有一个**被隐藏的**控制台;
    // 它的子进程会继承这个控制台,不再各自新建,于是一个窗口都不会弹。
    // (~/.agents/bin/_cicy-run.ps1 用的是同一招,但它只能藏住自己那层,管不到 dsh 起的父层。)
    const q = (x) => "'" + String(x).replace(/'/g, "''") + "'";
    const cmd = "$p = Start-Process -FilePath " + q(process.execPath)
      + " -ArgumentList @(" + [p.bin, "web", "--no-open", "--port", String(PORT)].map(q).join(",") + ")"
      + " -WindowStyle Hidden -RedirectStandardOutput " + q(logFile) + " -RedirectStandardError " + q(errFile)
      + " -WorkingDirectory " + q(home) + " -PassThru; $p.Id";
    try { fs.writeFileSync(logFile, ""); fs.writeFileSync(errFile, ""); } catch {}
    const r = cp.spawnSync("powershell", ["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", cmd], { encoding: "utf8", timeout: 60000, windowsHide: true, env });
    pid = Number(String((r && r.stdout) || "").match(/\d+/) || 0);
    if (!pid) return { ok: false, error: "start_failed", tail: String((r && r.stderr) || "").slice(-800) };
  } else {
    const fd = fs.openSync(logFile, "w");
    const child = cp.spawn(process.execPath, [p.bin, "web", "--no-open", "--port", String(PORT)], { detached: true, windowsHide: true, stdio: ["ignore", fd, fd], cwd: home, env });
    child.unref(); try { fs.closeSync(fd); } catch {}
    pid = child.pid;
  }
  try { fs.writeFileSync(envApplied, envHash()); } catch {}
  try { fs.writeFileSync(pidFile, String(pid)); } catch {}
  // dsh web 冷启动在矩阵机上实测常超过 30s(加载 ~200MB 依赖);只要进程还活着就等到 120s,别误报 start_failed
  for (let i = 0; i < 240; i++) { await sleep(500); if (await httpUp()) return { ok: true, pid, token: lastToken(), seededWorkspaces: seeded.seeded }; if (!pidAlive(pid)) break; }
  let tail = ""; try { tail = tailOf(logFile, 4096).slice(-1200) + "\n--- stderr ---\n" + tailOf(errFile, 2048).slice(-600); } catch {}
  return { ok: false, error: "start_failed", pid, tail };
}
async function stop() {
  const pid = readPidFile(pidFile); let killed = false;
  if (pid && pidAlive(pid)) { try { process.kill(pid); killed = true; } catch {} }
  if (!killed) {
    // pid 文件丢了/不是我们拉起的:按端口找(powershell 用绝对路径,别指望 PATH)
    try {
      if (process.platform === "win32") run(q(path.join(process.env.SystemRoot || "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe")) + ' -NoProfile -Command "Get-NetTCPConnection -LocalPort ' + PORT + ' -State Listen -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force }"', { timeout: 20000 });
      else run("lsof -ti tcp:" + PORT + " | xargs kill 2>/dev/null", { timeout: 20000 });
      killed = true;
    } catch {}
  }
  for (let i = 0; i < 10; i++) { if (!(await httpUp())) break; await sleep(500); }
  try { fs.unlinkSync(pidFile); } catch {}
  return { ok: true, killed, running: await httpUp() };
}
async function open() {
  const up = await httpUp(); const t = lastToken();
  return { ok: up, url: "http://127.0.0.1:" + PORT + "/" + (t ? "?token=" + t : "") };
}
(async () => {
  try { out(OP === "install" ? await install() : OP === "start" ? await start() : OP === "stop" ? await stop() : OP === "open" ? await open() : await status()); }
  catch (e) { out({ ok: false, error: String((e && e.message) || e) }); }
})();
`;

// 解析工具层标准返回 { content:[{text}], isError }:text 是 JSON {stdout, stderr, exitCode}。
function parseRpcText(res) {
  if (!res) return { ok: false, stdout: "", stderr: "no response", code: 1 };
  const txt = (res.content || []).map((c) => c.text).filter(Boolean).join("\n");
  let j = null; try { j = JSON.parse(txt); } catch {}
  if (!j || typeof j !== "object") return { ok: !res.isError, stdout: txt, stderr: "", code: res.isError ? 1 : 0 };
  const code = Number(j.exitCode ?? (res.isError ? 1 : 0));
  return { ok: !res.isError && code === 0, stdout: j.stdout || "", stderr: j.stderr || "", code };
}
async function dshShell(command) { return parseRpcText(await window.electronRPC?.("exec_shell", { command })); }
const dshIsWin = () => (window.cicy?.platform || "") === "win32";
// 找 node/npm/home,结果按页面缓存。installNode=true 时 Windows 缺 Node 会当场下载便携版(慢,只在安装流程里用)。
let _dshEnv = null;
async function dshEnv({ installNode = false } = {}) {
  if (_dshEnv && _dshEnv.node) return _dshEnv;
  const win = dshIsWin();
  const pick = (text) => ({
    node: (text.match(/^NODE=(.+)$/m) || [])[1]?.trim() || "",
    home: (text.match(/^HOME=(.+)$/m) || [])[1]?.trim() || "",
    err: (text.match(/^ERR=(.+)$/m) || [])[1]?.trim() || "",
    major: Number((text.match(/^v(\d+)\./m) || [])[1] || 0),
    src: [...text.matchAll(/^SRC=(.+)$/mg)].map((m) => m[1].trim()).pop() || "",
    secs: Number((text.match(/^SECS=(\d+)/m) || [])[1] || 0),
  });
  let r = await dshShell(win ? DSH_FIND_NODE_CMD : DSH_NODE_SH);
  let t = pick((r.stdout || "") + "\n" + (r.stderr || ""));
  let node = t.node, home = t.home, err = "", src = "", secs = 0;
  if (win && node && t.major && t.major < 22) node = "";   // 有 node 但太旧:当没有,装便携版(cicy-node 排在候选首位,下次直接命中)
  if (!node && win && installNode) {
    // 脚本超过 cmd 8K 命令行上限,不能走 -EncodedCommand;写成文件再 -File 跑
    const ps1 = `${home}\\cicy-ai\\db\\dsh-node.ps1`;
    // 带 BOM:无 BOM 的 .ps1 会被 PowerShell 5.1 按 ANSI(GBK)读,脚本里一个非 ASCII 字符就能把下一行吞进注释
    const w = parseRpcText(await window.electronRPC?.("file_write", { path: ps1, content: "\uFEFF" + dshInstallNodePs1() }));
    if (!w.ok) return { node: "", home, error: "ps1_write_failed" };
    r = await dshShell(`"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "${ps1}"`);
    const t2 = pick((r.stdout || "") + "\n" + (r.stderr || ""));
    node = t2.node; err = t2.err; src = t2.src; secs = t2.secs;
    if (!node && !err) err = (r.stderr || r.stdout || "").trim().slice(-300);
  }
  if (!node) return { node: "", home, error: err };
  const dir = node.replace(/[\\/][^\\/]+$/, "");
  const npm = win ? `${dir}\\npm.cmd` : `${dir}/npm`;
  const ctl = win ? `${home}\\cicy-ai\\db\\dsh-ctl.js` : `${home}/cicy-ai/db/dsh-ctl.js`;
  _dshEnv = { node, npm, home, ctl, written: false, src, secs };
  return _dshEnv;
}
async function dshCtl(op, opts = {}) {
  const env = await dshEnv(opts);
  if (!env.node) return { ok: false, error: "no_node", detail: env.error || "" };
  if (!env.written) {
    const w = parseRpcText(await window.electronRPC?.("file_write", { path: env.ctl, content: DSH_CTL_SCRIPT }));
    if (!w.ok) return { ok: false, error: "ctl_write_failed", detail: w.stderr || w.stdout };
    env.written = true;
  }
  const r = await dshShell(`"${env.node}" "${env.ctl}" ${op} "${env.npm}"`);
  const m = String(r.stdout || "").trim().match(/\{[\s\S]*\}$/);
  if (m) { try { return JSON.parse(m[0]); } catch {} }
  return { ok: false, error: "ctl_no_output", detail: (r.stderr || r.stdout || "").trim().slice(-400) };
}
function DshIcon() {
  return (
    <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
      <path d="M4 17c3.5 0 4.5-3 8-3s4.5 3 8 3" />
      <path d="M4 12c3.5 0 4.5-3 8-3s4.5 3 8 3" />
      <path d="M4 7c3.5 0 4.5-3 8-3s4.5 3 8 3" />
    </svg>
  );
}

function DshCard() {
  const [st, setSt] = useState(null);       // dshCtl("status") 结果;null = 还没探过
  const [busy, setBusy] = useState("");     // "" | install | start | stop | open
  const [menuOpen, setMenuOpen] = useState(false);
  const [menuPos, setMenuPos] = useState({ top: 0, left: 0 });
  const [auto, setAuto] = useState(() => { try { return localStorage.getItem(DSH_AUTO_KEY) !== "off"; } catch { return true; } });
  const kebabRef = useRef(null);
  const menuRef = useRef(null);
  const autoRan = useRef(false);
  const busyRef = useRef("");
  useEffect(() => { busyRef.current = busy; }, [busy]);
  const MENU_W = 184;

  const refresh = useCallback(async () => {
    try { const s = await dshCtl("status"); setSt(s); return s; }
    catch (e) { console.warn("[DshCard] status", e); return null; }
  }, []);
  useEffect(() => {
    refresh();
    const id = setInterval(() => { if (!busyRef.current) refresh(); }, 20000);
    return () => clearInterval(id);
  }, [refresh]);

  // 车队级配置(hub 经 fleet ws 下发的 dsh_config:API Key 等):写到 ~/cicy-ai/db/dsh-env.json,
  // dsh-ctl start 把它并进启动环境。key 变了且 dsh 在跑 → 静默重启一次;没跑且已装 → 拉起。
  const appliedRev = useRef("");
  const applyFleetCfg = useCallback(async () => {
    const cfg = window.__dshFleetCfg;
    if (!cfg || busyRef.current) return;
    if (cfg.rev === appliedRev.current) return;
    try {
      const env = await dshEnv();
      if (!env.home) return;
      const f = dshIsWin() ? `${env.home}\\cicy-ai\\db\\dsh-env.json` : `${env.home}/cicy-ai/db/dsh-env.json`;
      const w = parseRpcText(await window.electronRPC?.("file_write", { path: f, content: JSON.stringify({ rev: cfg.rev, env: cfg.env, workspaces: cfg.workspaces || [] }, null, 2) }));
      if (!w.ok) return;
      appliedRev.current = cfg.rev;
      if (!env.node) return;                      // 还没装 Node:runSetup 装完 start 时自然带上
      const s = await dshCtl("status");
      if (s.error) return;
      if (s.running && (s.envStale || s.wsStale)) { setBusy("start"); try { await dshCtl("stop"); await dshCtl("start"); } finally { setBusy(""); } }   // key 变了 / 默认工作区还没种 → 重启一次(种工作区要在 dsh 停着时改 workspace.json)
      else if (!s.running && s.installed) { setBusy("start"); try { await dshCtl("start"); } finally { setBusy(""); } }
      refresh();
    } catch (e) { console.warn("[DshCard] fleet cfg", e); }
  }, [refresh]);
  useEffect(() => {
    applyFleetCfg();
    window.addEventListener("cicy:dsh-config", applyFleetCfg);
    return () => window.removeEventListener("cicy:dsh-config", applyFleetCfg);
  }, [applyFleetCfg]);

  // 安装(或重装)+ 启动:全程走 Docker 同款抽屉(标题/副标题换成 dsh 的)。
  // 四段:准备环境(找/装 Node)→ 下载运行环境(npm 装 dsh)→ 启动服务(dsh web)→ 完成
  const runSetup = useCallback(async (opts = {}) => {
    if (busyRef.current) return;
    setBusy("install");
    dockerDrawer.open({ kind: "dsh", title: tr("dsh.setupTitle", "安装 DeepSeek Harness"), sub: `127.0.0.1:${DSH_PORT}`, onRetry: () => runSetup(opts) });
    const push = (phase, message, status) => dockerDrawer.push({ phase, message, status: status || "running" });
    const fail = (message) => { try { localStorage.setItem(dshFailKey(), String(Date.now())); } catch {} dockerDrawer.finish({ ok: false, message }); };
    let hb = null;
    // 长命令期间没有进度事件;定时 push 同一条消息只刷新 lastAt(日志去重不会重复打行)。
    const heartbeat = (phase, msg) => { push(phase, msg); hb = setInterval(() => push(phase, msg), 10000); };
    const stopHb = () => { if (hb) { clearInterval(hb); hb = null; } };
    try {
      push("install-docker", tr("dsh.checkNode", "检查 Node.js…"));
      let env = await dshEnv();
      if (!env.node) {
        if (!dshIsWin()) { fail(tr("dsh.needNode", "需要 Node.js 22+,请先安装:{{url}}", { url: "https://nodejs.org/en/download" })); return; }
        heartbeat("install-docker", tr("dsh.installingNode", "这台机器没有 Node.js,下载便携版 {{v}}(约 37MB,免管理员)…", { v: DSH_NODE_VERSION }));
        // 下载是同步的 PowerShell,拿不到进度事件;每 3s 用一条 cmd 读 .part 文件大小驱动进度条(不刷日志)
        const partCmd = `for %I in ("%LOCALAPPDATA%\\cicy-node-dl\\node-${DSH_NODE_VERSION}-win-x64.zip.part") do @echo %~zI`;
        const barLabel = tr("dsh.nodeZip", "Node.js 便携版");
        let polling = false;
        const prog = setInterval(async () => {
          if (polling) return; polling = true;
          try { const r = await dshShell(partCmd); const n = Number(String(r.stdout || "").trim()); if (n > 0) dockerDrawer.push({ phase: "install-docker", status: "running", progress: Math.min(99, Math.round(n * 100 / DSH_NODE_ZIP_BYTES)), received: n, total: DSH_NODE_ZIP_BYTES, label: barLabel }); }
          catch {} finally { polling = false; }
        }, 3000);
        try { env = await dshEnv({ installNode: true }); } finally { clearInterval(prog); stopHb(); }
        if (!env.node) { fail(tr("dsh.nodeFailed", "Node.js 便携版安装失败:{{why}}", { why: env.error || "download" })); return; }
        if (env.src) dockerDrawer.push({ phase: "install-docker", status: "done", progress: 100, received: DSH_NODE_ZIP_BYTES, total: DSH_NODE_ZIP_BYTES, url: env.src, label: barLabel, message: tr("dsh.nodeDownloaded", "Node.js 便携版下载完成({{s}}s)", { s: env.secs }) });
      }
      const nodeHow = env.src ? ` (${env.src.replace(/^https?:\/\//, "").split("/")[0]}, ${env.secs}s)` : "";
      push("install-docker", tr("dsh.nodeReady", "Node.js 就绪:{{p}}", { p: env.node }) + nodeHow, "done");
      let s = await dshCtl("status"), instHow = "";
      if (s.error) { fail(tr("dsh.ctlFailed", "控制脚本无法运行:{{why}}", { why: s.error + (s.detail ? " · " + s.detail : "") })); return; }
      if (!s.installed || opts.reinstall) {
        const label = `${DSH_PKG}@${DSH_VERSION}`;
        heartbeat("image", s.installing
          ? tr("dsh.installingElsewhere", "另一个安装进程正在进行,等待它完成…")
          : tr("dsh.installing", "npm 安装 {{pkg}}…(视网络 1~3 分钟)", { pkg: label }));
        const r = await dshCtl("install");
        stopHb();
        if (r.ok && r.registry) instHow = ` (${r.registry.replace(/^https?:\/\//, "")}, ${r.secs}s)`;
        if (!r.ok) { fail(tr("dsh.installFailed", "安装失败:{{why}}", { why: [r.error, r.tail, r.detail].filter(Boolean).join(" · ").split(/\r?\n/).filter(Boolean).slice(-3).join(" · ") })); return; }
        s = await dshCtl("status");
      }
      push("image", tr("dsh.installed", "已安装 v{{v}}", { v: s.version }) + (instHow || ""), "done");
      push("container", tr("dsh.starting", "启动 dsh web(:{{port}})…", { port: DSH_PORT }));
      const r2 = await dshCtl("start");
      if (!r2.ok) {
        const tail = String(r2.tail || "").trim().split(/\r?\n/).filter(Boolean).slice(-2).join(" · ");
        fail(tr("dsh.startFailed", "启动失败:{{why}}", { why: tail || r2.error || "start_failed" }));
        return;
      }
      try { localStorage.removeItem(dshFailKey()); } catch {}
      dockerDrawer.finish({ ok: true, message: tr("dsh.ready", "DeepSeek Harness 已就绪") });
    } catch (e) {
      dockerDrawer.finish({ ok: false, message: e.message });
    } finally {
      stopHb();
      setBusy(""); refresh();
    }
  }, [refresh]);

  const startSilent = useCallback(async () => {
    if (busyRef.current) return;
    setBusy("start");
    try {
      const r = await dshCtl("start");
      if (!r.ok) toast.show({ id: "dsh-op", status: "error", ttl: 6000, message: tr("dsh.startFailed", "启动失败:{{why}}", { why: r.error || "start_failed" }) });
    } catch (e) { toast.show({ id: "dsh-op", status: "error", ttl: 6000, message: e.message }); }
    finally { setBusy(""); refresh(); }
  }, [refresh]);

  // 开机自装/自启:首页每次加载只跑一次(首次 status 回来时决定)。
  useEffect(() => {
    if (autoRan.current || !st) return;
    if (st.installing) return;               // 别的页面/进程正在装 → 下轮 status 再看
    // no_node 不算失败:开机自装本来就要下载便携版 Node,交给 runSetup。其它 error(ctl 跑不了)才放弃。
    if (st.error && st.error !== "no_node") return;
    autoRan.current = true;
    if (!auto || st.running) return;
    if (st.installed) { startSilent(); return; }
    let failAt = 0; try { failAt = Number(localStorage.getItem(dshFailKey()) || 0); } catch {}
    if (Date.now() - failAt < DSH_FAIL_BACKOFF_MS) return;   // 这版首页上刚自动装失败过,别每次开机都重来
    runSetup();
  }, [st, auto, runSetup, startSilent]);

  const runOp = async (kind, fn, okMsg) => {
    if (busyRef.current) return;
    setMenuOpen(false); setBusy(kind);
    try {
      const r = await fn();
      if (r && r.ok === false) toast.show({ id: "dsh-op", status: "error", ttl: 6000, message: r.error || tr("docker.opFailed", "操作失败") });
      else if (okMsg) toast.show({ id: "dsh-op", status: "ok", ttl: 3000, message: okMsg });
    } catch (e) { toast.show({ id: "dsh-op", status: "error", ttl: 6000, message: e.message }); }
    finally { setBusy(""); refresh(); }
  };

  const openUi = async () => {
    if (busyRef.current) return;
    setBusy("open");
    try {
      const r = await dshCtl("open");
      if (!r.ok) { toast.show({ id: "dsh-open", status: "error", ttl: 6000, message: tr("dsh.notRunning", "dsh 还没运行,先点「启动」") }); return; }
      await window.cicy?.tabs?.open?.(r.url, "DeepSeek Harness", "", true);
    } catch (e) { toast.show({ id: "dsh-open", status: "error", ttl: 6000, message: e.message }); }
    finally { setBusy(""); }
  };

  const toggleAuto = () => {
    const next = !auto; setAuto(next); setMenuOpen(false);
    try { if (next) localStorage.removeItem(DSH_AUTO_KEY); else localStorage.setItem(DSH_AUTO_KEY, "off"); } catch {}
  };

  const toggleMenu = (e) => {
    e.stopPropagation();
    if (!menuOpen && kebabRef.current) {
      const r = kebabRef.current.getBoundingClientRect();
      const left = Math.max(8, Math.min(r.right - MENU_W, window.innerWidth - MENU_W - 8));
      setMenuPos({ top: Math.round(r.bottom + 4), left: Math.round(left) });
    }
    setMenuOpen((v) => !v);
  };
  useEffect(() => {
    if (!menuOpen) return;
    const onDoc = (e) => {
      if (kebabRef.current?.contains(e.target) || menuRef.current?.contains(e.target)) return;
      setMenuOpen(false);
    };
    const onKey = (e) => { if (e.key === "Escape") setMenuOpen(false); };
    document.addEventListener("mousedown", onDoc);
    document.addEventListener("keydown", onKey);
    return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onKey); };
  }, [menuOpen]);

  if (typeof window !== "undefined" && !window.electronRPC) return null;   // 非桌面壳里没有本机执行桥,不展示

  const running = !!st?.running;
  const installed = !!st?.installed;
  const isBusy = !!busy;
  const tone = running ? "ok" : installed ? "warn" : "off";
  const ctaLabel = busy === "open" ? tr("docker.opening", "打开中…")
    : busy === "install" ? tr("dsh.installingShort", "安装中…")
    : busy === "start" ? tr("dsh.startingShort", "启动中…")
    : isBusy ? tr("docker.working", "处理中…")
    : !st ? tr("docker.probing", "检测中…")
    : running ? tr("localTeams.open", "打开")
    : installed ? tr("docker.start", "启动")
    : tr("dsh.install", "安装");
  const onCta = () => {
    if (isBusy || !st) return;
    if (running) return openUi();
    if (installed && !st.error) return startSilent();
    return runSetup();
  };
  const stateText = !st ? ""
    : st.error ? tr("dsh.stateNoNode", "未就绪:{{why}}", { why: st.error === "no_node" ? tr("dsh.noNodeShort", "没有 Node.js(点「安装」自动装便携版)") : st.error })
    : st.installing ? tr("dsh.stateInstalling", "另一个安装进程进行中…")
    : running ? tr("dsh.stateRunning", "运行中")
    : st.broken ? tr("dsh.stateBroken", "安装不完整(点「安装」修复)")
    : installed ? tr("dsh.stateStopped", "已安装,未运行")
    : tr("dsh.stateMissing", "未安装");

  return (
    <div data-id="DshCard" className={`bcard bcard--dsh${running ? " bcard--online" : ""}`}>
      <div className="bcard__accent" />
      <div className="bcard__top">
        <div className="bcard__pill" style={{ color: DSH_BLUE }}>
          <span className="bcard__dot" data-tone={tone} />
          <DshIcon />
        </div>
        {installed && (
          <div className="bcard__menuwrap" onClick={(e) => e.stopPropagation()}>
            <button type="button" ref={kebabRef} data-id="DshCard-menu-btn" className="bcard__kebab" title={tr("dsh.manage", "管理 DeepSeek Harness")} disabled={isBusy} onClick={toggleMenu}>
              {isBusy ? <Spinner /> : <KebabIcon />}
            </button>
            {menuOpen && createPortal(
              <div className="bcard__menu bcard__menu--portal" data-id="DshCard-menu" role="menu" ref={menuRef}
                style={{ position: "fixed", top: menuPos.top, left: menuPos.left, width: MENU_W }}
                onClick={(e) => e.stopPropagation()}>
                <button type="button" data-id="DshCard-restart" className="bcard__menu-item is-accent"
                  onClick={() => runOp("start", async () => { await dshCtl("stop"); return dshCtl("start"); }, tr("dsh.restarted", "已重启 dsh"))}>
                  {tr("dsh.restart", "重启 dsh")}
                </button>
                <button type="button" data-id="DshCard-stop" className="bcard__menu-item is-danger"
                  onClick={() => runOp("stop", () => dshCtl("stop"), tr("dsh.stopped", "已停止 dsh"))}>
                  {tr("dsh.stop", "停止 dsh")}
                </button>
                <button type="button" data-id="DshCard-reload" className="bcard__menu-item"
                  onClick={(e) => { e.stopPropagation(); setMenuOpen(false); window.cicy?.tabs?.reloadIfOpen?.(`http://127.0.0.1:${DSH_PORT}`, "DeepSeek Harness"); }}>
                  {tr("docker.reloadWindow", "刷新窗口")}
                </button>
                <div className="bcard__menu-sep" role="separator" aria-hidden />
                <button type="button" data-id="DshCard-auto" className="bcard__menu-item" onClick={toggleAuto}>
                  {auto ? tr("dsh.autoOn", "✓ 开机自动启动") : tr("dsh.autoOff", "开机自动启动:关")}
                </button>
                <button type="button" data-id="DshCard-reinstall" className="bcard__menu-item"
                  title={tr("dsh.reinstallHint", "重新 npm 安装锁定版本 {{v}} 并重启", { v: DSH_VERSION })}
                  onClick={() => { setMenuOpen(false); runSetup({ reinstall: true }); }}>
                  {tr("dsh.reinstall", "重新安装")}
                </button>
              </div>,
              document.body
            )}
          </div>
        )}
      </div>
      <div className="bcard__body">
        <div style={{ height: 28, display: "flex", alignItems: "center", gap: 8 }}>
          <h3 className="bcard__name" title="DeepSeek Harness" style={{ margin: 0, flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>DeepSeek Harness</h3>
        </div>
        <div className="bcard__meta">
          <span className="bcard__chip">dsh</span>
          {st?.version && <span className="bcard__ver" data-id="DshCard-ver" style={{ marginLeft: 8, fontSize: 11, opacity: 0.6 }}>v{st.version}</span>}
        </div>
        {stateText && <div data-id="DshCard-state" title={stateText} style={{ marginTop: 6, fontSize: 12, color: running ? "#8b949e" : "#9aa4b2", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{stateText}</div>}
      </div>
      <button type="button" className="bcard__cta" data-id="DshCard-cta" disabled={isBusy || !st} onClick={onCta}
        style={!running ? { background: DSH_BLUE, color: "white" } : undefined}>
        {isBusy ? <Spinner /> : <ArrowIcon />}
        <span>{ctaLabel}</span>
      </button>
    </div>
  );
}

// Docker-版 cicy-code card (Windows only): a SECOND cicy-code instance running
// in Docker on :8008, alongside the native local daemon (:8008). If Docker
// Desktop is missing, the install flow downloads its installer to the user's
// Desktop and runs it, streaming progress through the drawer above.
function DockerCard({ dockerTeam, cloudTitle, cloudCode, onOpen, onRename, onRefresh }) {
  const [status, setStatus] = useState(null);
  const [busy, setBusy] = useState("");   // "" | bootstrap | restart | stop | upgrade | probe
  const [menuOpen, setMenuOpen] = useState(false);
  const [doodOpen, setDoodOpen] = useState(false); // 容器内使用 Docker(DooD)modal(卡片层,菜单外)
  const [confirmRecreate, setConfirmRecreate] = useState(false); // 重建容器 in-app 确认弹窗(不用 native confirm)
  const [portsOpen, setPortsOpen] = useState(false);   // 端口设置 modal
  const [portList, setPortList] = useState([]);         // 编辑中的额外端口(字符串数组,便于输入)
  const [portsBusy, setPortsBusy] = useState(false);
  const [portsErr, setPortsErr] = useState("");
  // Inline rename (mirrors LocalTeamCard): double-click the title to edit.
  const [editing, setEditing] = useState(false);
  const [draft, setDraft] = useState("");
  // 标题 = 云端 team 的 title(和其它团队同一套;refreshCloudTeams 周期刷新 → 自动跟随
   // 云端改名)。还没建好云端 team 时回退 "Docker 团队"。
  const displayName = cloudTitle || "Docker 团队";
  const startEdit = (e) => { e?.stopPropagation?.(); setDraft(displayName); setEditing(true); };
  const commitName = async () => {
    setEditing(false);
    const next = String(draft || "").trim();
    if (!next || next === displayName || !onRename) return;
    try { await onRename(next); onRefresh?.(); } catch {}  // onRename 走 renameCloudTeam(PATCH)
  };
  const [menuPos, setMenuPos] = useState({ top: 0, left: 0 });
  const kebabRef = useRef(null);
  const menuRef = useRef(null);
  const MENU_W = 184;
  const DOCKER_BLUE = "#2496ed";

  const checkStatus = useCallback(async () => {
    try {
      const s = await window.cicy?.docker?.appStatus?.();
      setStatus(s);
      // 自愈卡死的设置抽屉:容器已健康(running)说明 setup 实际已完成,但抽屉可能还停在
      // 「进行中」—— 网络失败重试后,follower 跟随的 bootstrap promise 迟迟不 resolve、又收
      // 不到 docker:app-progress 完成事件,抽屉就一直转(用户看到的「正在跟随同一进度」假死)。
      // 这里检测到容器起来了就直接把抽屉收成「完成」,不再死等 promise。
      // kind!=="open":只自愈安装抽屉;「打开」抽屉(失败报告)绝不被「已就绪」劫持。
      // **只自愈真正卡住的抽屉**(>10s 没收到任何进度事件):否则会误杀正在进行的「更新/
      // 重启 cicy-code」——它们是 in-place 操作,容器全程 healthy,点更新瞬间 busy 变化触发
      // 一次 checkStatus 就会把还在跑的抽屉提前收成「完成」,用户点完成关掉、命令却还在跑、
      // 卡片卡在「处理中」(实测 bug)。活跃流式的抽屉 lastAt 是新的,不动它。
      const stale = Date.now() - (dockerDrawerState?.lastAt || 0) > 30000;
      // kind 只认 install(缺省):"open" 是失败报告,"dsh" 是 DeepSeek Harness 卡借用的抽屉(npm 安装
      // 可能 1-2 分钟没进度事件,不能被 Docker 的"已就绪"劫持收掉)。
      if (s?.running && dockerDrawerState && dockerDrawerState.status === "running" && (dockerDrawerState.kind || "install") === "install" && stale) {
        dockerDrawer.finish({ ok: true, message: "Docker cicy-code 已就绪" });
      }
    } catch (e) { console.warn("[DockerCard]", e); }
  }, []);

  // Poll so the card reflects reality even when Docker changes outside the app
  // (user installs Docker / the engine comes up after a reboot / a container
  // starts). Pause polling while an op is running (the op refreshes itself).
  useEffect(() => {
    checkStatus();
    const id = setInterval(() => { if (!busy) checkStatus(); }, 12000);
    return () => clearInterval(id);
  }, [checkStatus, busy]);

  // 端口设置(定义在 checkStatus 之后:savePorts 依赖它,放前面会 TDZ 崩首页)。
  const openPorts = useCallback(async () => {
    setMenuOpen(false); setPortsErr("");
    try { const r = await window.cicy?.docker?.getPorts?.(); setPortList((r?.ports || []).map(String)); }
    catch { setPortList([]); }
    setPortsOpen(true);
  }, []);
  const savePorts = useCallback(async () => {
    // 校验:1-65535、≠8008、去重、忽略空行。
    const seen = new Set(); const out = [];
    for (const raw of portList) {
      const s = String(raw).trim(); if (!s) continue;
      const p = Number(s);
      if (!Number.isInteger(p) || p < 1 || p > 65535 || p === 8008 || seen.has(p)) { setPortsErr(tr("docker.ports.invalid", "有端口无效(1-65535,不能是 8008,不能重复)")); return; }
      seen.add(p); out.push(p);
    }
    setPortsErr(""); setPortsBusy(true); setPortsOpen(false);
    setBusy("recreate");
    dockerDrawer.open({ onRetry: savePorts });
    const unsub = window.cicy?.docker?.onAppProgress?.((ev) => dockerDrawer.push(ev));
    try {
      const r = await window.cicy?.docker?.setPorts?.(out);
      dockerDrawer.finish({ ok: !!r?.ok, message: r?.ok ? tr("docker.ports.save", "保存并重建") + " ✅" : (r?.error || tr("docker.opFailed", "操作失败")) });
      if (r?.ok) { try { const s = await window.cicy?.docker?.appRedetect?.(); if (s) setStatus(s); } catch {} }
    } catch (e) {
      dockerDrawer.finish({ ok: false, message: e.message });
    } finally {
      try { unsub && unsub(); } catch {}
      setPortsBusy(false); setBusy(""); checkStatus();
    }
  }, [portList, checkStatus]);

  // Close the ⋯ menu on outside-click / Esc (mirrors LocalTeamCard).
  useEffect(() => {
    if (!menuOpen) return;
    const onDoc = (e) => {
      if (kebabRef.current?.contains(e.target) || menuRef.current?.contains(e.target)) return;
      setMenuOpen(false);
    };
    const onKey = (e) => { if (e.key === "Escape") setMenuOpen(false); };
    document.addEventListener("mousedown", onDoc);
    document.addEventListener("keydown", onKey);
    return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onKey); };
  }, [menuOpen]);

  const toggleMenu = () => {
    if (!menuOpen && kebabRef.current) {
      const r = kebabRef.current.getBoundingClientRect();
      const left = Math.max(8, Math.min(r.right - MENU_W, window.innerWidth - MENU_W - 8));
      setMenuPos({ top: Math.round(r.bottom + 4), left: Math.round(left) });
    }
    setMenuOpen((v) => !v);
  };

  // Install / start: streams through the drawer modal (logs + progress + retry).
  const runBootstrap = useCallback(async () => {
    setBusy("bootstrap");
    dockerDrawer.open({ onRetry: runBootstrap });
    const unsub = window.cicy?.docker?.onAppProgress?.((ev) => dockerDrawer.push(ev));
    try {
      const r = await window.cicy?.docker?.appBootstrap?.();
      if (r?.reason === "installer_launched") {
        dockerDrawer.finish({ status: "reboot", message: tr("docker.installerLaunched", "已打开 Docker 安装程序——请完成安装（会装 WSL2、可能需重启），装好后点「重试」") });
      } else if (r?.reason === "wsl_reboot_required") {
        dockerDrawer.finish({ status: "reboot", message: tr("docker.rebootNeeded", "WSL2 已安装，请【重启 Windows】后回来点「重试」继续") });
      } else {
        dockerDrawer.finish({ ok: !!r?.ok, message: r?.ok ? tr("docker.ready", "Docker cicy-code 已就绪") : (r?.error || tr("docker.failed", "安装失败")) });
      }
      if (r?.ok) onRefresh?.();
    } catch (e) {
      dockerDrawer.finish({ ok: false, message: e.message });
    } finally {
      try { unsub && unsub(); } catch {}
      setBusy(""); checkStatus();
    }
  }, [checkStatus, onRefresh]);

  // 修复 WSL(WSL 死锁时):重启 LxssManager;若深度死锁(杀不动)→ 提示重启电脑(重启后
  // bootstrap 自动重置坏 distro)。全程走抽屉显示进度。
  const repairWsl = useCallback(async () => {
    setBusy("repair");
    dockerDrawer.open({ onRetry: repairWsl });
    const unsub = window.cicy?.docker?.onAppProgress?.((ev) => dockerDrawer.push(ev));
    try {
      const r = await window.cicy?.docker?.appRepairWsl?.();
      if (r?.needsReboot) {
        dockerDrawer.finish({ status: "reboot", message: tr("docker.wslNeedReboot", "WSL 服务卡死,请【重启 Windows】;重启后打开 CiCy 会自动修复(旧数据已改名备份,不删)。") });
      } else if (r?.ok) {
        dockerDrawer.finish({ ok: true, message: tr("docker.wslRepaired", "WSL 已修复 ✅") });
        onRefresh?.();
      } else {
        dockerDrawer.finish({ ok: false, message: r?.error || tr("docker.repairFailed", "修复失败") });
      }
    } catch (e) {
      dockerDrawer.finish({ ok: false, message: e.message });
    } finally {
      try { unsub && unsub(); } catch {}
      setBusy(""); checkStatus();
    }
  }, [checkStatus, onRefresh]);

  // Upgrade: re-pull the R2 image + recreate the container — also through the
  // drawer so the user sees the pull/import/restart log (升级要能看日志).
  const runUpgrade = useCallback(async () => {
    setMenuOpen(false); setBusy("upgrade");
    dockerDrawer.open({ onRetry: runUpgrade });
    const unsub = window.cicy?.docker?.onAppProgress?.((ev) => dockerDrawer.push(ev));
    try {
      const r = await window.cicy?.docker?.appUpgrade?.();
      if (r?.reason === "wsl_reboot_required") {
        dockerDrawer.finish({ status: "reboot", message: tr("docker.rebootNeeded", "WSL2 已安装，请【重启 Windows】后回来点「重试」继续") });
      } else {
        dockerDrawer.finish({ ok: !!r?.ok, message: r?.ok ? tr("docker.upgraded", "已升级到最新") : (r?.error || tr("docker.upgradeFailed", "升级失败")) });
      }
      if (r?.ok) onRefresh?.();
    } catch (e) {
      dockerDrawer.finish({ ok: false, message: e.message });
    } finally {
      try { unsub && unsub(); } catch {}
      setBusy(""); checkStatus();
    }
  }, [checkStatus, onRefresh]);

  // Update cicy-code (in-place: pull latest + supervisorctl restart). Drawer so
  // the user sees the npm pull + restart log.
  const runUpdate = useCallback(async () => {
    setMenuOpen(false); setBusy("update");
    dockerDrawer.open({ onRetry: runUpdate });
    const unsub = window.cicy?.docker?.onAppProgress?.((ev) => dockerDrawer.push(ev));
    try {
      const r = await window.cicy?.docker?.appUpdate?.();
      dockerDrawer.finish({ ok: !!r?.ok, message: r?.ok ? tr("docker.updated", "cicy-code 已更新到最新") : (r?.error || tr("docker.updateFailed", "更新失败")) });
      if (r?.ok) {
        onRefresh?.();
        // update() 返回时 :8008 已健康(waitUntil probeHealth)。立刻:① reload 已打开的
        // docker tab(reloadIgnoringCache,拿新版 SPA)② 强制重探,卡片版本马上更新(不等
        // 60s reconcile / 缓存)。
        try { window.cicy?.tabs?.reloadIfOpen?.("http://127.0.0.1:8008", tr("docker.teamTab", "Docker 团队")); } catch {}
        try { const s = await window.cicy?.docker?.appRedetect?.(); if (s) setStatus(s); } catch {}
      }
    } catch (e) {
      dockerDrawer.finish({ ok: false, message: e.message });
    } finally {
      try { unsub && unsub(); } catch {}
      setBusy(""); checkStatus();
    }
  }, [checkStatus, onRefresh]);

  // Restart / stop: quick lifecycle ops with a toast (no full drawer needed).
  const runOp = useCallback(async (op, fn, okMsg) => {
    setMenuOpen(false); setBusy(op);
    toast.show({ id: "docker-op", message: tr(`docker.${op}ing`, op === "restart" ? "重启中…" : op === "reload" ? "刷新中…" : "停止中…"), status: "running" });
    try {
      const r = await fn();
      if (r?.ok) toast.show({ id: "docker-op", message: okMsg, status: "done", ttl: 2500 });
      else toast.show({ id: "docker-op", message: (r?.error || tr("docker.opFailed", "操作失败")), status: "error", ttl: 6000 });
    } catch (e) {
      toast.show({ id: "docker-op", message: e.message, status: "error", ttl: 6000 });
    } finally { setBusy(""); checkStatus(); }
  }, [checkStatus]);

  // 「授权容器访问 Mac」(仅 macOS,不挂 docker):把容器公钥写进 Mac 的 authorized_keys +
  // 容器里写 ssh config 的 `mac` 别名,之后容器内 `ssh mac` 即可访问 Mac 主机跑命令。
  const authorizeHostSsh = useCallback(async () => {
    setMenuOpen(false); setBusy("authssh");
    toast.show({ id: "docker-op", message: tr("docker.authorizingHost", "授权容器访问 Mac(配置 SSH)…"), status: "running" });
    try {
      const r = await window.cicy.docker.appAuthorizeHostSsh();
      if (r?.ok) toast.show({ id: "docker-op", message: r.verified ? tr("docker.authorizedHostOk", "已授权:容器内 `ssh mac` 可访问 Mac ✅") : tr("docker.authorizedHostPartial", "公钥已写入 Mac(未验证成功,可在容器内手动 `ssh mac` 试)"), status: r.verified ? "done" : "error", ttl: 5000 });
      else toast.show({ id: "docker-op", message: (r?.error || tr("docker.opFailed", "操作失败")), status: "error", ttl: 6000 });
    } catch (e) {
      toast.show({ id: "docker-op", message: e.message, status: "error", ttl: 6000 });
    } finally { setBusy(""); checkStatus(); }
  }, [checkStatus]);

  // Chrome 代理已无开关:docker 装好后宿主 mihomo 自动起(后端始终开启、bootstrap 预下载二进制)。

  // Render on Windows (WSL2) only. (2026-06 回调): macOS 改回 native cicy-code(:8008),
  // 不再有 Docker 卡 —— mac 的本地团队走 LocalTeamCard。window.cicy.platform is sync.
  const platform = window.cicy?.platform || status?.platform;
  if (platform !== "win32") return null;

  // Distinct states (状态分清楚):
  //   running       — :8008 container healthy → 打开
  //   dockerRunning — engine up, no container → 启动 (build/start container)
  //   installed     — Docker on disk but engine down → 启动 Docker
  //   else          — not installed → 下载安装
  // 有 live status 就信它;status 还没加载到(null)才用 dockerTeam 快照兜底。
  // 否则 teams.json 里陈旧的 status:"running" 会盖过「容器其实已停/:8008 down」的真实
  // 状态 → 卡片显示死「打开」(点了必失败)。真相单一,以 live 探测为准。
  const running = status ? !!status.running : (dockerTeam?.status === "running");
  const dockerRunning = !!status?.dockerRunning;
  const installed = !!status?.installed;
  // unknown = the status probe couldn't reach WSL (stuck / still booting after a
  // reboot). Do NOT fall through to 「下载安装」— that lies (it IS installed, WSL
  // just didn't answer). Show a retry state so the user re-probes, not reinstalls.
  const unknown = !!status?.unknown && !running && !dockerRunning && !installed;
  // WSL 被孤儿化::8008 看着健康(可能只是 wslhost 僵尸攥着端口),但 distro 已从 WSL
  // 消失 → token 读不到、打不开。所以 wslUnmanaged 不再是「可打开」,而是「需修复」:
  // CTA 走「修复 WSL」自动重装(bootstrap 会杀僵尸端口 + wsl --shutdown + 重新 import)。
  const wslUnmanaged = !!status?.wslUnmanaged;
  const wslWedged = !!status?.wslWedged; // WSL 死锁(LxssManager StopPending,所有 wsl 命令 hang)
  const realRunning = running && !wslUnmanaged; // 真能打开 = 健康 且 WSL 没孤儿化
  const tone = realRunning ? "ok" : (wslWedged || wslUnmanaged || dockerRunning || installed || unknown) ? "warn" : "off";
  const isBusy = !!busy;
  const stateText = wslWedged
    ? tr("docker.wslWedged", "WSL 卡死 · 点「修复 WSL」")
    : wslUnmanaged
    ? tr("docker.wslBrokenRepair", "WSL 管理异常 · 点「修复 WSL」重装")
    : running
    ? tr("docker.running", "运行中")
    : dockerRunning
      ? tr("docker.notRunning", "未启动 · 点「启动」")
      : installed
        ? tr("docker.engineDown", "Docker 未运行 · 点启动")
        : unknown
          ? tr("docker.wslUnresponsive", "WSL 未响应 · 点「重试检测」")
          : tr("docker.notInstalled", "Docker Desktop 未安装");

  const ctaLabel = busy === "open"
    ? tr("docker.opening", "打开中…")
    : busy === "probe"
    ? tr("docker.probing", "检测中…")
    : isBusy
    ? tr("docker.working", "处理中…")
    : realRunning
      ? tr("localTeams.open", "打开")
      : (wslWedged || wslUnmanaged)
        ? tr("docker.repairWsl", "修复 WSL")
      : dockerRunning
        ? tr("docker.start", "启动")
        : installed
          ? tr("docker.startDocker", "启动 Docker")
          : unknown
            ? tr("docker.retryProbe", "重试检测")
            : tr("docker.install", "下载安装");

  const onCta = async () => {
    if (isBusy) return;
    // WSL 死锁 → 走「修复 WSL」(重启 LxssManager;不行则提示重启电脑 + 重启后自动重置坏 distro)。
    if (wslWedged) { repairWsl(); return; }
    // WSL 孤儿化 → 走「修复」(bootstrap 杀僵尸端口 + wsl --shutdown + 重新 import),不进打开。
    if (wslUnmanaged) { runBootstrap(); return; }
    if (realRunning) {
      // 打开很慢 → 先探这个 :8008 tab 开过没。开过(openedWc 里有它的
      // webContentsId)就**直接 active 秒切**,不再拿 token / 注册 team(那是慢的根)。
      try {
        const r = await window.cicy?.tabs?.activateIfOpen?.("http://127.0.0.1:8008");
        if (r?.active) return;
      } catch {}
      // 没开过 → 走慢路径(读容器 token 最多 ~50s)。进行中只显示 CTA 的「打开中…」spinner
      // (不复用安装 stepper,免得看着像全绿其实在重试)。成功=静默秒开;失败=才开 drawer,
      // 一次性回放全部命令/输出/错误日志 + 可排查 hint + 重试,直接定格在红色失败态。
      setBusy("open");
      let res = null;
      try { res = await window.cicy?.docker?.appOpen?.(); }
      catch (e) { res = { ok: false, hint: e?.message || tr("docker.openFailed", "打开失败"), log: [] }; }
      setBusy("");
      if (res && res.ok === false) {
        dockerDrawer.open({ onRetry: onCta, kind: "open" });
        (res.log || []).forEach((l) => dockerDrawer.push(l));
        dockerDrawer.finish({ ok: false, message: res.hint || res.error || tr("docker.openFailed", "打开失败") });
      }
      return;
    }
    // 「重试检测」: FORCE a fresh WSL probe (checkStatus only re-read the cache, so
    // clicking did nothing — the「点了没反应」bug). Show a spinner so the click always
    // gives feedback; if WSL is STILL stuck after a real probe, say so clearly.
    if (unknown) {
      setBusy("probe");
      try {
        const s = await (window.cicy?.docker?.appRedetect?.() ?? window.cicy?.docker?.appStatus?.());
        if (s) setStatus(s);
        if (s?.unknown) toast.show({ id: "docker-probe", status: "error", message: tr("docker.wslStillStuck", "WSL 仍无响应 —— 多半要重启 Windows 后再试"), ttl: 6000 });
      } catch (e) { toast.show({ id: "docker-probe", status: "error", message: tr("docker.probeFailed", "检测失败,请重试"), ttl: 5000 }); }
      finally { setBusy(""); }
      return;
    }
    runBootstrap();
  };

  // The ⋯ menu (重启 / 停止 / 升级) only makes sense once the container is up.
  const showMenu = running;

  return (
    <div data-id="DockerCard" className={`bcard bcard--docker${running ? " bcard--online" : ""}`}>
      <div className="bcard__accent" style={{ background: DOCKER_BLUE }} />
      <div className="bcard__top">
        <div className="bcard__pill" style={{ color: DOCKER_BLUE }}>
          <span className="bcard__dot" data-tone={tone} />
          <svg style={{ width: 18, height: 18 }} viewBox="0 0 24 24" fill="currentColor" aria-hidden>
            <path d="M21.81 10.25c-.06-.05-.67-.51-1.95-.51-.34 0-.68.03-1.01.09-.25-1.69-1.64-2.51-1.7-2.55l-.34-.2-.22.32a4.5 4.5 0 0 0-.59 1.4c-.23.94-.09 1.83.39 2.59-.58.32-1.51.4-1.7.41H2.62a.61.61 0 0 0-.61.61 9.32 9.32 0 0 0 .57 3.35 4.9 4.9 0 0 0 1.95 2.53c.92.52 2.42.82 4.12.82.77 0 1.54-.07 2.3-.21a9.6 9.6 0 0 0 3-1.09 8.3 8.3 0 0 0 2.05-1.68c.98-1.11 1.56-2.35 1.99-3.45h.17c1.36 0 2.2-.55 2.66-1l.13-.16zM4.7 11.33h1.78a.16.16 0 0 0 .16-.16V9.58a.16.16 0 0 0-.16-.16H4.7a.16.16 0 0 0-.16.16v1.59c0 .09.07.16.16.16m2.46 0h1.78a.16.16 0 0 0 .16-.16V9.58a.16.16 0 0 0-.16-.16H7.16a.16.16 0 0 0-.16.16v1.59c0 .09.07.16.16.16m2.5 0h1.78a.16.16 0 0 0 .16-.16V9.58a.16.16 0 0 0-.16-.16H9.66a.16.16 0 0 0-.16.16v1.59c0 .09.07.16.16.16m2.47 0h1.78a.16.16 0 0 0 .16-.16V9.58a.16.16 0 0 0-.16-.16h-1.78a.16.16 0 0 0-.16.16v1.59c0 .09.07.16.16.16M7.16 9.06h1.78a.16.16 0 0 0 .16-.16V7.31a.16.16 0 0 0-.16-.16H7.16a.16.16 0 0 0-.16.16v1.59c0 .09.07.16.16.16m2.5 0h1.78a.16.16 0 0 0 .16-.16V7.31a.16.16 0 0 0-.16-.16H9.66a.16.16 0 0 0-.16.16v1.59c0 .09.07.16.16.16m2.47 0h1.78a.16.16 0 0 0 .16-.16V7.31a.16.16 0 0 0-.16-.16h-1.78a.16.16 0 0 0-.16.16v1.59c0 .09.07.16.16.16" />
          </svg>
        </div>
        {showMenu && (
          <div className="bcard__menuwrap" onClick={(e) => e.stopPropagation()}>
            <button
              type="button"
              ref={kebabRef}
              data-id="DockerCard-menu-btn"
              className="bcard__kebab"
              title={tr("docker.manage", "管理 Docker cicy-code")}
              disabled={isBusy}
              onClick={toggleMenu}
            >
              {isBusy ? <Spinner /> : <KebabIcon />}
            </button>
            {menuOpen && createPortal(
              <div className="bcard__menu bcard__menu--portal" data-id="DockerCard-menu" role="menu"
                ref={menuRef}
                style={{ position: "fixed", top: menuPos.top, left: menuPos.left, width: MENU_W }}
                onClick={(e) => e.stopPropagation()}>
                {/* cicy-code 操作(容器内的 cicy-code:更新 / 重启 / 停止)*/}
                <button type="button" data-id="DockerCard-update" className="bcard__menu-item is-accent" onClick={runUpdate}>
                  {tr("docker.update", "更新 cicy-code")}
                </button>
                <button type="button" data-id="DockerCard-restart" className="bcard__menu-item"
                  onClick={() => runOp("restart", () => window.cicy.docker.appRestart(), tr("docker.restarted", "已重启 cicy-code"))}>
                  {tr("docker.restart", "重启 cicy-code")}
                </button>
                <button type="button" data-id="DockerCard-stop" className="bcard__menu-item is-danger"
                  onClick={() => runOp("stop", () => window.cicy.docker.appStop(), tr("docker.stopped", "已停止 cicy-code"))}>
                  {tr("docker.stop", "停止 cicy-code")}
                </button>
                {/* 常用 */}
                <button type="button" data-id="DockerCard-reload" className="bcard__menu-item"
                  onClick={(e) => { e.stopPropagation(); setMenuOpen(false); window.cicy?.tabs?.reloadIfOpen?.("http://127.0.0.1:8008", "Docker 团队"); }}>
                  {tr("docker.reloadWindow", "刷新窗口")}
                </button>
                <button type="button" data-id="DockerCard-open-dir" className="bcard__menu-item"
                  onClick={(e) => { e.stopPropagation(); setMenuOpen(false); window.cicy?.docker?.openDir?.(); }}>
                  {tr("docker.openWslDir", "打开 WSL 目录")}
                </button>
                <button type="button" data-id="DockerCard-open-projects" className="bcard__menu-item"
                  onClick={(e) => { e.stopPropagation(); setMenuOpen(false); window.cicy?.docker?.openDir?.("projects"); }}>
                  {tr("docker.openProjectsDir", "打开项目目录")}
                </button>
                <button type="button" data-id="DockerCard-dood" className="bcard__menu-item"
                  title={tr("dood.hint", "把宿主 Docker + docker 客户端挂进容器,容器内 agent 能直接跑 docker(切换会重建容器,秒生效、无需下载)")}
                  onClick={(e) => { e.stopPropagation(); setMenuOpen(false); setDoodOpen(true); }}>
                  {tr("dood.menu", "容器内使用 Docker")}
                </button>
                {/* 分隔线:下面是操作整个 Docker 容器的(授权访问 Mac / 重启 Docker / 重建 Docker)*/}
                <div className="bcard__menu-sep" data-id="DockerCard-menu-sep" role="separator" aria-hidden />
                {/* 仅 macOS:授权容器经 SSH 访问 Mac 主机(host.docker.internal),不挂 docker */}
                {platform === "darwin" && (
                  <button type="button" data-id="DockerCard-authorize-host-ssh" className="bcard__menu-item"
                    title={tr("docker.authorizeHostHint", "把容器公钥加到 Mac 的 authorized_keys,容器内 `ssh mac` 即可访问本机")}
                    onClick={authorizeHostSsh}>
                    {tr("docker.authorizeHost", "授权容器访问 Mac")}
                  </button>
                )}
                {/* Chrome 代理已无开关:docker 装好后宿主 mihomo 自动起(始终开启),不再手动切换 */}
                <button type="button" data-id="DockerCard-docker-restart" className="bcard__menu-item"
                  onClick={() => runOp("restart", () => window.cicy.docker.appDockerRestart(), tr("docker.dockerRestarted", "已重启 Docker 容器"))}>
                  {tr("docker.dockerRestart", "重启 Docker")}
                </button>
                <button type="button" data-id="DockerCard-ports" className="bcard__menu-item"
                  onClick={(e) => { e.stopPropagation(); openPorts(); }}>
                  {tr("docker.ports.menu", "端口设置")}
                </button>
                <button type="button" data-id="DockerCard-recreate" className="bcard__menu-item is-danger"
                  onClick={(e) => { e.stopPropagation(); setMenuOpen(false); setConfirmRecreate(true); }}>
                  {tr("docker.recreate", "重建 Docker")}
                </button>
              </div>,
              document.body
            )}
          </div>
        )}
      </div>
      <div className="bcard__body">
        {/* 8008 现在有独立云端 team(cloud_team_id 是它自己的,不再和 8008 串),所以
            标题可改名:本地节点名 + 云端 PATCH 双写(onRename 在父组件处理)。 */}
        <div style={{ height: 28, display: "flex", alignItems: "center", gap: 8 }}>
          <TeamAvatar size={24} avatar={dockerTeam?.avatar} name={displayName} teamId={dockerTeam?.id} onChanged={onRefresh} />
          {editing ? (
            <input
              data-id="DockerCard-rename-input"
              autoFocus
              value={draft}
              onChange={(e) => setDraft(e.target.value)}
              onFocus={(e) => e.target.select()}
              onBlur={commitName}
              onClick={(e) => e.stopPropagation()}
              onKeyDown={(e) => { if (e.nativeEvent.isComposing || e.keyCode === 229) return; if (e.key === "Enter") commitName(); else if (e.key === "Escape") setEditing(false); }}
              style={{ flex: 1, width: "100%", font: "inherit", fontWeight: 600, padding: "2px 6px", border: "1px solid #3b82f6", borderRadius: 6, background: "#0d1117", color: "#e6edf3", boxSizing: "border-box" }}
            />
          ) : (
            <h3 className="bcard__name" title={onRename ? tr("localTeams.renameHint", "点名字或 ✎ 改名") : displayName} style={{ display: "flex", alignItems: "center", gap: 6, flex: 1, minWidth: 0, margin: 0 }} onDoubleClick={onRename ? startEdit : undefined}>
              <span onClick={onRename ? startEdit : undefined} style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", cursor: onRename ? "text" : "default" }}>{displayName}</span>
              {onRename && (
                <button type="button" data-id="DockerCard-rename-btn" title={tr("localTeams.rename", "重命名")} onClick={startEdit} style={{ flex: "none", cursor: "pointer", border: "none", background: "transparent", color: "#8b949e", fontSize: 13, padding: 0, lineHeight: 1 }}>✎</button>
              )}
            </h3>
          )}
        </div>
        <div className="bcard__meta">
          <span className="bcard__chip">Docker</span>
          {status?.version && <span className="bcard__ver" data-id="DockerCard-ver" style={{ marginLeft: 8, fontSize: 11, opacity: 0.6 }}>v{status.version}</span>}
        </div>
      </div>
      <button
        type="button"
        className="bcard__cta"
        data-id="DockerCard-cta"
        disabled={isBusy}
        onClick={onCta}
        style={!running ? { background: DOCKER_BLUE, color: "white" } : undefined}
      >
        {isBusy ? <Spinner /> : <ArrowIcon />}
        <span>{ctaLabel}</span>
      </button>
      <DoodModal open={doodOpen} onClose={() => setDoodOpen(false)} toastId="docker-op" />
      {confirmRecreate && createPortal(
        <div data-id="DockerCard-recreate-modal"
          style={{ position: "fixed", inset: 0, zIndex: 9999, background: "rgba(0,0,0,0.55)", display: "flex", alignItems: "center", justifyContent: "center" }}
          onMouseDown={(e) => { if (e.target === e.currentTarget) setConfirmRecreate(false); }}>
          <div onClick={(e) => e.stopPropagation()}
            style={{ width: 360, maxWidth: "90vw", background: "#161b22", border: "1px solid #30363d", borderRadius: 12, padding: "20px 22px", boxShadow: "0 12px 40px rgba(0,0,0,0.5)" }}>
            <h3 style={{ margin: "0 0 8px", fontSize: 16, color: "#e6edf3" }}>{tr("docker.recreate", "重建 Docker")}</h3>
            <p style={{ margin: "0 0 18px", fontSize: 13, lineHeight: 1.6, color: "#9aa4b2" }}>
              {tr("docker.recreateConfirm", "会删除当前容器并重新创建(volume 数据保留),用于切换为独立 team 的网关 key。确定重建?")}
            </p>
            <div style={{ display: "flex", justifyContent: "flex-end", gap: 10 }}>
              <button type="button" data-id="DockerCard-recreate-cancel"
                onClick={() => setConfirmRecreate(false)}
                style={{ padding: "7px 16px", borderRadius: 8, border: "1px solid #30363d", background: "transparent", color: "#c9d1d9", cursor: "pointer", fontSize: 13 }}>
                {tr("common.cancel", "取消")}
              </button>
              <button type="button" data-id="DockerCard-recreate-confirm"
                onClick={() => { setConfirmRecreate(false); runOp("restart", () => window.cicy.docker.appRecreate(), tr("docker.recreated", "已重建 Docker 容器")); }}
                style={{ padding: "7px 16px", borderRadius: 8, border: "none", background: "#da3633", color: "white", cursor: "pointer", fontSize: 13, fontWeight: 600 }}>
                {tr("docker.recreateOk", "确定重建")}
              </button>
            </div>
          </div>
        </div>,
        document.body
      )}
      {portsOpen && createPortal(
        <div data-id="DockerCard-ports-modal"
          style={{ position: "fixed", inset: 0, zIndex: 9999, background: "rgba(0,0,0,0.55)", display: "flex", alignItems: "center", justifyContent: "center" }}
          onMouseDown={(e) => { if (e.target === e.currentTarget) setPortsOpen(false); }}>
          <div onClick={(e) => e.stopPropagation()}
            style={{ width: 420, maxWidth: "92vw", background: "#161b22", border: "1px solid #30363d", borderRadius: 12, padding: "20px 22px", boxShadow: "0 12px 40px rgba(0,0,0,0.5)" }}>
            <h3 style={{ margin: "0 0 6px", fontSize: 16, color: "#e6edf3" }}>{tr("docker.ports.title", "端口设置")}</h3>
            <p style={{ margin: "0 0 14px", fontSize: 12.5, lineHeight: 1.6, color: "#9aa4b2" }}>{tr("docker.ports.sub", "除 :8008 外,额外发布、可从 Windows 直达容器内服务的端口")}</p>
            <div style={{ display: "flex", alignItems: "center", gap: 8, padding: "8px 10px", marginBottom: 10, borderRadius: 8, background: "#0d1117", border: "1px solid #21262d", color: "#7d8590", fontSize: 13 }}>
              🔒 {tr("docker.ports.mainFixed", ":8008 · cicy-code(固定)")}
            </div>
            <div style={{ display: "flex", flexDirection: "column", gap: 8, maxHeight: 220, overflowY: "auto" }}>
              {portList.length === 0 && (
                <div style={{ fontSize: 12.5, color: "#6e7681", padding: "4px 2px" }}>{tr("docker.ports.none", "暂无额外端口")}</div>
              )}
              {portList.map((p, i) => (
                <div key={i} style={{ display: "flex", alignItems: "center", gap: 8 }}>
                  <input data-id={`DockerCard-port-input-${i}`} type="text" inputMode="numeric" value={p}
                    placeholder={tr("docker.ports.ph", "端口号 1-65535")}
                    onChange={(e) => { const v = e.target.value.replace(/[^0-9]/g, ""); setPortList((list) => list.map((x, j) => j === i ? v : x)); setPortsErr(""); }}
                    style={{ flex: 1, padding: "7px 10px", borderRadius: 8, border: "1px solid #30363d", background: "#0d1117", color: "#e6edf3", fontSize: 13, fontFamily: "var(--mono)" }} />
                  <button type="button" data-id={`DockerCard-port-remove-${i}`} title={tr("docker.ports.remove", "移除")}
                    onClick={() => { setPortList((list) => list.filter((_, j) => j !== i)); setPortsErr(""); }}
                    style={{ padding: "6px 10px", borderRadius: 8, border: "1px solid #30363d", background: "transparent", color: "#da3633", cursor: "pointer", fontSize: 14 }}>✕</button>
                </div>
              ))}
            </div>
            <button type="button" data-id="DockerCard-port-add"
              onClick={() => setPortList((list) => [...list, ""])}
              style={{ marginTop: 10, padding: "7px 12px", borderRadius: 8, border: "1px dashed #30363d", background: "transparent", color: "#58a6ff", cursor: "pointer", fontSize: 13, width: "100%" }}>
              {tr("docker.ports.add", "+ 添加端口")}
            </button>
            {portsErr && <div style={{ marginTop: 10, fontSize: 12.5, color: "#f85149" }}>{portsErr}</div>}
            <p style={{ margin: "12px 0 16px", fontSize: 11.5, lineHeight: 1.5, color: "#6e7681" }}>{tr("docker.ports.hint", "保存会重建容器(volume 数据保留),会有短暂中断")}</p>
            <div style={{ display: "flex", justifyContent: "flex-end", gap: 10 }}>
              <button type="button" data-id="DockerCard-ports-cancel" disabled={portsBusy}
                onClick={() => setPortsOpen(false)}
                style={{ padding: "7px 16px", borderRadius: 8, border: "1px solid #30363d", background: "transparent", color: "#c9d1d9", cursor: "pointer", fontSize: 13 }}>
                {tr("docker.ports.cancel", "取消")}
              </button>
              <button type="button" data-id="DockerCard-ports-save" disabled={portsBusy}
                onClick={savePorts}
                style={{ padding: "7px 16px", borderRadius: 8, border: "none", background: "#238636", color: "white", cursor: "pointer", fontSize: 13, fontWeight: 600 }}>
                {portsBusy ? tr("docker.ports.saving", "保存中…") : tr("docker.ports.save", "保存并重建")}
              </button>
            </div>
          </div>
        </div>,
        document.body
      )}
    </div>
  );
}

function LocalTeamCard({ team, cloudCode, onOpen, onRename, onRefresh }) {
  const statusInfo = LOCAL_STATUS[team.status] || LOCAL_STATUS.error;
  const tone = statusInfo.tone;
  // Inline rename — 产品级:点名字即编辑、乐观更新、行内"保存中/已保存"、失败回滚。
  // 显示名 = pendingName(乐观,保存中暂显)?? team.name(props,后台对账后更新)。
  const [editing, setEditing] = useState(false);
  const [draft, setDraft] = useState(team.name || "");
  const [pendingName, setPendingName] = useState(null);     // 乐观名;props 追上后清
  const [saveState, setSaveState] = useState("");            // "" | saving | saved | error
  const displayName = pendingName != null ? pendingName : team.name;
  // props 追上乐观名 → 清 pending(两者相等,无闪烁)
  useEffect(() => { if (pendingName != null && team.name === pendingName) setPendingName(null); }, [team.name, pendingName]);
  const startEdit = (e) => { e?.stopPropagation?.(); setDraft(displayName || ""); setEditing(true); };
  const commit = async () => {
    setEditing(false);
    const next = String(draft || "").trim();
    if (!onRename || !next || next === displayName) return;
    setPendingName(next);            // 乐观:立即显示新名
    setSaveState("saving");
    let r;
    try { r = await onRename(team.id, next); } catch (e) { r = { ok: false, error: e?.message }; }
    // 落定:让权威值(team.name,onRename 已刷新)接管显示。服务端权威下,本端改名若与
    // 云端并发冲突会被判负,后续 ~3s 对账会把名字换成云端版本——清掉乐观名才不会卡住。
    setPendingName(null);
    if (r && r.ok) {
      setSaveState("saved");
      setTimeout(() => setSaveState((s) => (s === "saved" ? "" : s)), 1500);
    } else {
      setSaveState("");
      toast.show({ message: tr("localTeams.renameFailed", "改名没保存,已恢复"), status: "error", ttl: 4000 });
    }
  };

  // Lifecycle (启动 / 重启 / 更新 / 停止) acts on the daemon the desktop OWNS —
  // localhost on the sidecar port (:8008). A remote node or a non-8008 port
  // can't be controlled from here (sidecar.* would hit the wrong, local :8008),
  // so those cards get 打开 only — no ⋯ menu, no update prompt. 打开 stays the
  // one primary action; maintenance lives in the ⋯ menu.
  const hasBridge = !!window.cicy?.sidecar?.restart;
  const local = hasBridge && isLocalSidecar(team.base_url);
  const running = team.status === "running";
  const [busy, setBusy] = useState("");   // "" | start | restart | update | stop | lan
  const [menuOpen, setMenuOpen] = useState(false);
  // 局域网访问开关: cicy-code --public 状态。仅本地团队;初始从 sidecar.getPublic() 读。
  const [lanOn, setLanOn] = useState(false);
  useEffect(() => {
    if (!local || !window.cicy?.sidecar?.getPublic) return;
    window.cicy.sidecar.getPublic().then((r) => setLanOn(!!r?.public)).catch(() => {});
  }, [local]);
  // cicy-code 版本统一从 sidecar.versions() 一处拿("拿版本就一个方法")。
  // running===undefined = 还没查到(用于区分"加载中" vs "停了/拿不到");区别于
  // running===null(查过了但 daemon 没报版本)。latest/installed 同源。
  const [versions, setVersions] = useState({ running: undefined, latest: null, installed: null });
  const latest = versions.latest;
  const runningVer = versions.running;
  const [checking, setChecking] = useState(false);
  const menuWrap = useRef(null);
  const kebabRef = useRef(null);   // ⋯ button — anchor for the portaled menu
  const menuRef = useRef(null);    // portaled menu (lives on document.body)
  const [menuPos, setMenuPos] = useState({ top: 0, left: 0 });
  const MENU_W = 184;
  // The ⋯ menu is rendered in a PORTAL on document.body (not inside .bcard, whose
  // overflow:hidden — needed for the rounded card + glow — would otherwise CLIP
  // the dropdown). Anchor it under the kebab, right-aligned, clamped on-screen.
  const toggleMenu = () => {
    if (!menuOpen && kebabRef.current) {
      const r = kebabRef.current.getBoundingClientRect();
      const left = Math.max(8, Math.min(r.right - MENU_W, window.innerWidth - MENU_W - 8));
      setMenuPos({ top: Math.round(r.bottom + 4), left: Math.round(left) });
    }
    setMenuOpen((v) => !v);
  };

  // 版本统一从 sidecar.versions() 一处拿(running=活着的 /api/health 版本,
  // latest=npm 最新,同源)。Auto-runs once on mount + 每次 daemon 起来(status→
  // running)时重查,这样 cicy-code 刚启动那一拍拿不到版本、之后能自动补上。
  // ⋯ 菜单"检查更新"用 manual=true 给 toast。
  const checkUpdate = useCallback(async (manual = false) => {
    if (!local || !window.cicy?.sidecar?.versions) return;
    if (manual) setChecking(true);
    try {
      const v = await window.cicy.sidecar.versions(); // { running, latest, installed }
      setVersions({ running: v?.running ?? null, latest: v?.latest ?? null, installed: v?.installed ?? null });
      if (manual) {
        if (v?.running && v?.latest && cmpVer(v.latest, v.running) > 0) {
          toast.show({ message: `${tr("sidecar.found", "发现新版本")} v${v.latest}`, status: "done", ttl: 2500 });
        } else if (v?.running) {
          toast.show({ message: `${tr("sidecar.upToDate", "已是最新")} v${v.running}`, status: "done", ttl: 2500 });
        } else {
          // daemon 没在跑 / 没报版本 — 别撒谎说最新,也别误报有更新
          toast.show({ message: tr("sidecar.notRunning", "cicy-code 未运行"), status: "error", ttl: 4000 });
        }
      }
    } catch { if (manual) toast.show({ message: tr("sidecar.checkFailed", "检查更新失败"), status: "error", ttl: 5000 }); }
    finally { if (manual) setChecking(false); }
  }, [local]);

  useEffect(() => { checkUpdate(false); }, [checkUpdate]);
  // daemon 起来后(或重启/更新后 status 翻 running)自动重查一次版本,补上启动早期的空值。
  useEffect(() => { if (running) checkUpdate(false); }, [running, checkUpdate]);

  // 只有在【确知运行版本 runningVer 且确实落后 latest】时才提示更新。runningVer 未知
  // (undefined/null:停了或刚启动还没读到)一律不提示——修掉"版本暂时读不到就误报更新 +
  // 卡片一直转/显示更新"的 bug。NOTE: 这跟"版本落后却不让更新"不冲突:落后是 runningVer
  // 已知且 < latest,照样提示。
  const updateAvailable = !!(local && latest && runningVer && cmpVer(latest, runningVer) > 0);
  // Custom (deeplink-added, non-local) nodes can be removed from the desktop —
  // it just drops them from cicyDesktopNodes; re-addable via deeplink. The
  // local sidecar isn't deletable here. So the ⋯ menu shows for a local card
  // with lifecycle, OR a custom card with just 删除.
  const isCustom = !local && !!window.cicy?.localTeams?.remove;
  // Local always gets the ⋯ menu (so 检查更新 is always reachable, even stopped);
  // custom gets it for 删除.
  const showMenu = local || isCustom;

  // Two-click delete guard, reset whenever the menu closes.
  const [confirmDel, setConfirmDel] = useState(false);
  // 自定义团队改 URL modal
  const [editingUrl, setEditingUrl] = useState(false);
  const [titleDraft, setTitleDraft] = useState("");
  const [urlDraft, setUrlDraft] = useState("");
  const [urlBusy, setUrlBusy] = useState(false);
  const [urlErr, setUrlErr] = useState("");

  useEffect(() => {
    if (!menuOpen) return;
    // The menu is portaled to document.body, so menuWrap no longer contains it —
    // check BOTH the kebab anchor and the portaled menu before closing.
    const onDoc = (e) => {
      if (kebabRef.current?.contains(e.target)) return;
      if (menuRef.current?.contains(e.target)) return;
      setMenuOpen(false);
    };
    document.addEventListener("mousedown", onDoc);
    return () => document.removeEventListener("mousedown", onDoc);
  }, [menuOpen]);

  // 自定义团队标题 + URL 一次保存。标题是用户自定义字段,绝不从 URL 自动生成。
  const commitUrl = async () => {
    if (urlBusy) return;
    const built = buildCustomTeamEditPatch({ title: titleDraft, url: urlDraft });
    if (!built.ok) { setUrlErr(tr("teams.titleRequired", "请输入自定义标题")); return; }
    if (!built.patch.base_url) { setUrlErr(tr("teams.urlRequired", "请输入地址 URL")); return; }
    try { new URL(built.patch.base_url); } catch { setUrlErr(tr("teams.badUrl", "URL 无效(需含 http(s)://)")); return; }
    if (built.patch.name === (team.name || "") && built.patch.base_url === (team.base_url || "")) { setEditingUrl(false); return; }
    setUrlErr(""); setUrlBusy(true);
    try {
      const r = await (window.cicy?.localTeams?.update?.(team.id, built.patch));
      if (r?.ok) { setEditingUrl(false); onRefresh?.(); }
      else setUrlErr(humanError(r?.error || "update failed"));
    } catch (e) { setUrlErr(humanError(e?.message || String(e))); }
    setUrlBusy(false);
  };
  const handleRemove = async () => {
    if (busy) return;
    setConfirmDel(false); setBusy("remove");
    const toastId = "op-" + Date.now();
    toast.show({ id: toastId, message: tr("localTeams.removing", "删除中…"), status: "running" });
    try {
      const r = await window.cicy?.localTeams?.remove?.(team.id);
      if (r?.ok) toast.show({ id: toastId, message: tr("localTeams.removed", "已删除"), status: "done", ttl: 3000 });
      else toast.show({ id: toastId, message: tr("localTeams.removeFailed", "删除失败") + (r?.error ? ": " + r.error : ""), status: "error", ttl: 7000 });
    } catch (e) { toast.show({ id: toastId, message: tr("localTeams.removeFailed", "删除失败") + ": " + (e?.message || e), status: "error", ttl: 7000 }); }
    setBusy(""); onRefresh?.();
  };

  // One toast per card-op, keyed by team — progress streams into it, the final
  // result replaces the message and auto-dismisses. Feedback floats over the UI
  // (toast), NOT inside the card; the card's only busy hint is the CTA spinner.
  const opToastId = `sidecar-op:${team.id}`;
  const runOp = async (kind, fn, doneText) => {
    setMenuOpen(false);
    if (busy) return;
    setBusy(kind);
    // 更新 gets its own drawer (live log + 阶段 + 重试); other ops use the toast.
    const isUpdate = kind === "update";
    let unsub = null;
    if (isUpdate) {
      updateDrawer.open({ teamId: team.id, fromVer: runningVer, toVer: latest, onRetry: () => runOp("update", fn, doneText) });
      if (window.cicy?.sidecar?.onOpProgress) {
        unsub = window.cicy.sidecar.onOpProgress((ev) => { if (ev?.op === "update") updateDrawer.push(ev); });
      }
    } else {
      toast.show({ id: opToastId, message: BUSY_LABEL[kind] || `${kind}…`, status: "running", progress: undefined });
    }
    try {
      const r = await fn();
      const ok = !!r?.ok;
      const okMsg = r?.warning ? `${doneText}（${r.warning}）` : doneText;
      const errMsg = tr("sidecar.failed", "操作失败") + (r?.error ? `: ${r.error}` : "");
      if (isUpdate) {
        updateDrawer.finish({ ok, message: ok ? okMsg : errMsg });
        // 更新成功 = 新 cicy-code 已切换并启动。若该团队的 :8008 窗口正开着,
        // 直接刷新它,让用户立刻用上新版(没开窗口则 reload 返回 no_open_window,no-op)。
        // ignoreCache:绕过 HTTP 缓存重载,否则可能复用缓存的旧 index.html → 仍跑旧版。
        if (ok) { try { await window.cicy?.localTeams?.reload?.(team.id, { ignoreCache: true }); } catch {} }
      } else {
        toast.show({ id: opToastId, message: ok ? okMsg : errMsg, progress: undefined, status: ok ? "done" : "error", ttl: ok ? 4000 : 8000 });
      }
    } catch (err) {
      const m = tr("sidecar.failed", "操作失败") + `: ${err?.message || err}`;
      if (isUpdate) updateDrawer.finish({ ok: false, message: m });
      else toast.show({ id: opToastId, message: m, progress: undefined, status: "error", ttl: 8000 });
    } finally {
      try { unsub && unsub(); } catch {}
      setBusy("");
      onRefresh?.(); // re-probe so the status dot/chip catches up
      // 重启/更新/启动后,daemon 版本可能变了(且 status 可能仍是 running、不会触发
      // 上面那个 effect),所以这里强制重查一次版本,卡片立刻反映真实版本。
      if (kind === "update" || kind === "restart" || kind === "start") checkUpdate(false);
    }
  };
  const BUSY_LABEL = { start: tr("busy.start", "启动中…"), restart: tr("busy.restart", "重启中…"), update: tr("busy.update", "更新中…"), stop: tr("busy.stop", "停止中…") };

  // 打开 flow (spec): start the LOCAL daemon if it's down (with a 启动中…
  // toast), then open. The window itself is opened by openTeam() in main, which
  // (1) reuses an already-open window for this team (list_windows check first),
  // and (2) for a local team, TCP-探活 until :8008 actually answers before
  // creating the window — so we never pop a blank page that needs a manual
  // reload. (/api/health is NOT used — it's unreliable mid-boot; the gate is a
  // raw TCP probe.) Remote/custom teams just open and show their own UI.
  // 启动本地 cicy-code,带**安装进度抽屉**(首次要装 Node + cicy-code 自己 brew 装
  // tmux 依赖,几分钟,必须让用户看见执行什么命令/卡在哪/出什么错,且能重试)。
  // sidecar:start 会流式 emit:Node 下载命令 + npx cicy-code + brew 装依赖的日志逐行推过来。
  const runStartFlow = async () => {
    setBusy("start");
    updateDrawer.open({ teamId: team.id, title: tr("sidecar.startTitle", "启动 cicy-code"), onRetry: runStartFlow });
    updateDrawer.push({ phase: "download", status: "running", message: tr("sidecar.starting", "启动 cicy-code(首次需安装 Node + 依赖,日志见下)…") });
    const unsub = window.cicy?.sidecar?.onOpProgress?.((ev) => updateDrawer.push(ev));
    let r;
    try { r = await window.cicy.sidecar.start(); } catch (e) { r = { ok: false, error: e?.message || String(e) }; }
    try { unsub?.(); } catch {}
    setBusy(""); onRefresh?.();
    if (!r?.ok || r?.warning) {
      updateDrawer.finish({ ok: false, message: tr("sidecar.startFailed", "启动失败") + (r?.error ? `: ${r.error}` : r?.warning ? `: ${r.warning}` : "") });
      return false;
    }
    updateDrawer.finish({ ok: true, message: tr("sidecar.startedOk", "cicy-code 已就绪") });
    return true;
  };
  const handleOpen = async () => {
    if (busy) return;
    if (!running && local && window.cicy?.sidecar?.start) {
      const ok = await runStartFlow();
      if (!ok) return; // 没起来 — 抽屉里有日志 + 重试,不开死链
    }
    onOpen(); // openTeam() gates on list_windows + TCP liveness before showing
  };
  const openLabel = running
    ? tr("localTeams.open", "打开")
    : local
      ? tr("localTeams.startOpen", "启动并打开") // only the local sidecar can be started from here
      : tr("localTeams.open", "打开");           // custom/remote: 探活-only, just open
  return (
    <>
    <div data-id="LocalTeamCard" className={`bcard ${local ? "bcard--local" : "bcard--custom"}${tone === "ok" ? " bcard--online" : ""}`}>
      <div className="bcard__accent" />
      <div className="bcard__top">
        <div className="bcard__pill">
          <span className="bcard__dot" data-tone={tone} />
          <LaptopIcon />
        </div>
        {showMenu && (
          <div className="bcard__menuwrap" ref={menuWrap} onClick={(e) => e.stopPropagation()}>
            <button
              type="button"
              ref={kebabRef}
              data-id="LocalTeamCard-menu-btn"
              className={`bcard__kebab${updateAvailable ? " has-dot" : ""}`}
              title={local ? tr("localTeams.manage", "管理本地 cicy-code") : tr("localTeams.more", "更多")}
              disabled={!!busy}
              onClick={toggleMenu}
            >
              {busy ? <Spinner /> : <KebabIcon />}
            </button>
            {menuOpen && createPortal(
              <div className="bcard__menu bcard__menu--portal" data-id="LocalTeamCard-menu" role="menu"
                ref={menuRef}
                style={{ position: "fixed", top: menuPos.top, left: menuPos.left, width: MENU_W }}
                onClick={(e) => e.stopPropagation()}>
                {local && (
                  <button
                    type="button"
                    data-id="LocalTeamCard-check-update"
                    className="bcard__menu-item"
                    disabled={checking}
                    onClick={(e) => { e.stopPropagation(); checkUpdate(true); }}
                  >
                    {checking ? tr("sidecar.checking2", "检查中…") : tr("sidecar.checkUpdate", "检查更新")}
                  </button>
                )}
                {updateAvailable && (
                  <button
                    type="button"
                    data-id="LocalTeamCard-update"
                    className="bcard__menu-item is-accent"
                    onClick={() => runOp("update", () => window.cicy.sidecar.update(), tr("sidecar.updated", "已更新到最新"))}
                  >
                    {tr("sidecar.updateTo", "更新到")} v{latest}
                  </button>
                )}
                {/* 刷新窗口:所有团队卡通用(本地/自定义)——不依赖 running(自定义节点
                    status 常非 running)。reloadIfOpen 没开 tab 自动不操作,所以总能显示。*/}
                {team.base_url && (
                  <button
                    type="button"
                    data-id="LocalTeamCard-reload"
                    className="bcard__menu-item"
                    onClick={(e) => { e.stopPropagation(); setMenuOpen(false); window.cicy?.tabs?.reloadIfOpen?.(team.base_url, team.name); }}
                  >
                    {tr("localTeams.reloadWindow", "刷新窗口")}
                  </button>
                )}
                {local && !running && (
                  <button
                    type="button"
                    data-id="LocalTeamCard-start"
                    className="bcard__menu-item is-accent"
                    onClick={() => runOp("start", () => window.cicy.sidecar.start(), tr("sidecar.started", "已启动"))}
                  >
                    {tr("sidecar.start", "启动")}
                  </button>
                )}
                {local && running && (
                  <>
                    <button
                      type="button"
                      data-id="LocalTeamCard-restart"
                      className="bcard__menu-item"
                      onClick={() => runOp("restart", () => window.cicy.sidecar.restart(), tr("sidecar.restarted", "已重启"))}
                    >
                      {tr("sidecar.restart", "重启")}
                    </button>
                    <button
                      type="button"
                      data-id="LocalTeamCard-stop"
                      className="bcard__menu-item is-danger"
                      onClick={() => runOp("stop", () => window.cicy.sidecar.stop(), tr("sidecar.stoppedDone", "已停止"))}
                    >
                      {tr("sidecar.stop", "停止")}
                    </button>
                    {/* 局域网访问紧挨「帐单」上方(放在 stop 之后)。 */}
                    <button
                      type="button"
                      data-id="LocalTeamCard-lan"
                      className="bcard__menu-item"
                      title={tr("sidecar.lanHint", "开启后同局域网设备可用本机 IP 访问(api_token 仍校验);切换会自动重启 cicy-code")}
                      disabled={busy === "lan"}
                      onClick={async () => {
                        if (busy) return;
                        const next = !lanOn;
                        setMenuOpen(false); setBusy("lan"); setLanOn(next);
                        toast.show({ id: opToastId, message: next ? tr("sidecar.lanEnabling", "开启局域网访问,重启中…") : tr("sidecar.lanDisabling", "关闭局域网访问,重启中…"), status: "running", progress: undefined });
                        try {
                          const r = await window.cicy.sidecar.setPublic(next);
                          if (r?.ok) toast.show({ id: opToastId, message: next ? tr("sidecar.lanOn", "已开启局域网访问") : tr("sidecar.lanOff", "已关闭局域网访问"), status: "done", ttl: 3000 });
                          else toast.show({ id: opToastId, message: tr("sidecar.lanFailed", "设置失败") + (r?.error ? `: ${r.error}` : ""), status: "error", ttl: 7000 });
                        } catch (e) { toast.show({ id: opToastId, message: tr("sidecar.lanFailed", "设置失败") + `: ${e?.message || e}`, status: "error", ttl: 7000 }); }
                        try { const g = await window.cicy.sidecar.getPublic(); setLanOn(!!g?.public); } catch {}
                        setBusy(""); onRefresh?.();
                      }}
                    >
                      {tr("sidecar.lanAccess", "局域网访问")} · {lanOn ? tr("common.on", "开") : tr("common.off", "关")}
                    </button>
                  </>
                )}
                {isCustom && (
                  <>
                    <button type="button" data-id="LocalTeamCard-edit-url" className="bcard__menu-item"
                      onClick={(e) => { e.stopPropagation(); setMenuOpen(false); setEditingUrl(true); setTitleDraft(team.name || ""); setUrlDraft(team.base_url || ""); setUrlErr(""); }}>
                      {tr("teamCard.editUrl", "更改访问地址")}
                    </button>
                    <button type="button" data-id="LocalTeamCard-remove"
                      className="bcard__menu-item is-danger"
                      onClick={() => { setMenuOpen(false); setConfirmDel(true); }}>
                      {tr("localTeams.remove", "删除")}
                    </button>
                  </>
                )}
                {(runningVer || team.version) && (
                  <div data-id="LocalTeamCard-version" className="bcard__menu-item" style={{ cursor: "default", color: "#8b949e", fontSize: 12 }}>
                    {tr("localTeams.version", "版本")} v{runningVer || team.version}
                  </div>
                )}
              </div>,
              document.body
            )}
          </div>
        )}
      </div>
      <div className="bcard__body">
        {/* 固定高度容器:h3 与 input 同高,切换不引起卡片位移 */}
        <div style={{ height: 28, display: "flex", alignItems: "center", gap: 8 }}>
        <TeamAvatar size={24} avatar={team?.avatar} name={team?.name} teamId={team?.id} onChanged={onRefresh} />
        {editing ? (
          <input
            data-id="LocalTeamCard-rename-input"
            autoFocus
            value={draft}
            onChange={(e) => setDraft(e.target.value)}
            onFocus={(e) => e.target.select()}
            onBlur={commit}
            onClick={(e) => e.stopPropagation()}
            onKeyDown={(e) => { if (e.nativeEvent.isComposing || e.keyCode === 229) return; if (e.key === "Enter") commit(); else if (e.key === "Escape") setEditing(false); }}
            style={{ flex: 1, width: "100%", font: "inherit", fontWeight: 600, padding: "2px 6px", border: "1px solid #3b82f6", borderRadius: 6, background: "#0d1117", color: "#e6edf3", boxSizing: "border-box" }}
          />
        ) : (
          <h3 className="bcard__name" title={tr("localTeams.renameHint", "点名字或 ✎ 改名")} style={{ display: "flex", alignItems: "center", gap: 6, flex: 1, minWidth: 0, margin: 0 }} onDoubleClick={startEdit}>
            <span
              data-id="LocalTeamCard-name-text"
              onClick={startEdit}
              style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", cursor: "text" }}
            >{displayName}</span>
            {/* 行内保存状态:保存中 spinner / 已保存 ✓ */}
            {saveState === "saving" && (
              <span data-id="LocalTeamCard-save-state" title={tr("localTeams.saving", "保存中…")} style={{ flex: "none", display: "inline-flex" }}><Spinner /></span>
            )}
            {saveState === "saved" && (
              <span data-id="LocalTeamCard-save-state" title={tr("localTeams.saved", "已保存")} style={{ flex: "none", color: "#3fb950", fontSize: 13, lineHeight: 1 }}>✓</span>
            )}
            {!saveState && (
              <button
                type="button"
                data-id="LocalTeamCard-rename-btn"
                title={tr("localTeams.rename", "重命名")}
                onClick={startEdit}
                style={{ flex: "none", cursor: "pointer", border: "none", background: "transparent", color: "#8b949e", fontSize: 13, padding: 0, lineHeight: 1 }}
              >✎</button>
            )}
          </h3>
        )}
        </div>
        <div className="bcard__meta">
          <span className="bcard__chip" data-id="LocalTeamCard-kind">{local ? tr("localTeams.kindLocal", "本地") : tr("localTeams.kindCustom", "自定义")}</span>
        </div>
      </div>
      <button
        type="button"
        className="bcard__cta"
        data-id="LocalTeamCard-open"
        disabled={!!busy || !team.base_url}
        onClick={handleOpen}
      >
        {busy && busy !== "stop" ? <Spinner /> : <ArrowIcon />}
        <span>{busy ? (BUSY_LABEL[busy] || openLabel) : openLabel}</span>
      </button>
    </div>
    {editingUrl && createPortal(
      <div data-id="LocalTeamCard-url-modal"
        style={{ position: "fixed", inset: 0, zIndex: 65, display: "flex", alignItems: "center", justifyContent: "center", background: "rgba(0,0,0,.5)" }}
        onMouseDown={(e) => { if (!urlBusy && e.target === e.currentTarget) setEditingUrl(false); }}>
        <div onClick={(e) => e.stopPropagation()}
          style={{ width: 400, maxWidth: "92vw", background: "var(--card, #1b1d22)", border: "1px solid var(--border, #2c2f36)", borderRadius: 14, padding: 22, boxShadow: "0 20px 60px rgba(0,0,0,.5)" }}>
          <div style={{ fontSize: 16, fontWeight: 700, marginBottom: 16 }}>{tr("teamCard.editCustom", "编辑自定义团队")}</div>
          <label style={{ display: "block", fontSize: 12, opacity: .75, marginBottom: 6 }}>{tr("teams.customNameLabel", "自定义标题")}</label>
          <input data-id="LocalTeamCard-title-input" autoFocus className="login-email-input" style={{ width: "100%", marginBottom: 14 }}
            value={titleDraft} placeholder={tr("teams.customNamePlaceholder", "我的团队")} spellCheck={false}
            disabled={urlBusy}
            onChange={(e) => setTitleDraft(e.target.value)}
            onKeyDown={(e) => { if (e.nativeEvent.isComposing || e.keyCode === 229) return; if (e.key === "Enter") { e.preventDefault(); commitUrl(); } else if (e.key === "Escape") setEditingUrl(false); }} />
          <label style={{ display: "block", fontSize: 12, opacity: .75, marginBottom: 6 }}>{tr("teams.customUrlLabel", "地址 URL")}</label>
          <textarea data-id="LocalTeamCard-url-input" rows={3} className="login-email-input" style={{ width: "100%", resize: "vertical", lineHeight: 1.5, fontFamily: "var(--mono)" }}
            value={urlDraft} placeholder="https://example.com:8008" spellCheck={false}
            disabled={urlBusy}
            onChange={(e) => setUrlDraft(e.target.value.replace(/[\r\n]+/g, ""))}
            onKeyDown={(e) => { if (e.nativeEvent.isComposing || e.keyCode === 229) return; if (e.key === "Enter") { e.preventDefault(); commitUrl(); } else if (e.key === "Escape") setEditingUrl(false); }} />
          {urlErr && <div className="error" style={{ marginTop: 8, fontSize: 12 }}>{urlErr}</div>}
          <div className="modal-actions">
            <button type="button" className="btn-ghost" disabled={urlBusy} onClick={() => setEditingUrl(false)}>{tr("common.cancel", "取消")}</button>
            <button type="button" className="btn-primary" data-id="LocalTeamCard-url-save" disabled={urlBusy} onClick={commitUrl}>{urlBusy ? tr("common.saving", "保存中…") : tr("common.save", "保存")}</button>
          </div>
        </div>
      </div>,
      document.body,
    )}
    <ConfirmModal open={confirmDel}
      title={tr("localTeams.deleteTitle", "删除团队")}
      message={tr("localTeams.deleteMsg", "确定删除「{{name}}」?此操作不可撤销。", { name: team.name })}
      confirmLabel={tr("common.delete", "删除")} danger
      onConfirm={handleRemove}
      onCancel={() => setConfirmDel(false)}
    />
    </>
  );
}

// True only for the daemon the desktop actually owns — localhost on the
// sidecar port (8008). Remote nodes / other ports can't be started from here.
// native cicy-code 本机 sidecar = localhost:8008,**只在 mac/linux**(main.js 里 native
// 仅非 Windows 启动)。Windows 上没有 native、8008 被 Docker-版占用(见 isDockerApp),所以
// Windows 上 8008 不算 native —— 否则同一个 8008 docker team 会既进 localList 又进 DockerCard,
// 渲染出两张卡(且 local 那张不带 live token,打开卡登录页)。两个判定按平台互斥。
function isLocalSidecar(baseUrl) {
  try {
    if (window.cicy?.platform === "win32") return false; // Windows 无 native:8008 归 docker
    const p = new URL(baseUrl);
    const local = p.hostname === "127.0.0.1" || p.hostname === "localhost" || p.hostname === "::1";
    return local && (p.port === "8008" || p.port === "");
  } catch { return false; }
}

// The Docker-版 cicy-code instance — localhost:8008, **只在 Windows**。Owned by
// <DockerCard>, so it's filtered out of the generic node lists.
function isDockerApp(baseUrl) {
  try {
    if (window.cicy?.platform !== "win32") return false; // docker-版只在 Windows
    const p = new URL(baseUrl);
    const local = p.hostname === "127.0.0.1" || p.hostname === "localhost" || p.hostname === "::1";
    return local && p.port === "8008";
  } catch { return false; }
}

// Compare dotted versions: >0 if a newer than b, <0 older, 0 equal.
function cmpVer(a, b) {
  const pa = String(a).split("."), pb = String(b).split(".");
  for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
    const d = (parseInt(pa[i], 10) || 0) - (parseInt(pb[i], 10) || 0);
    if (d) return d > 0 ? 1 : -1;
  }
  return 0;
}

const LOCAL_STATUS = {
  running:       { tone: "ok",   label: "running",    cta: tr("localStatus.open", "打开") },
  stopped:       { tone: "off",  label: "stopped",    cta: tr("localStatus.notRunning", "未运行") },
  auth_error:    { tone: "warn", label: "auth error", cta: tr("localStatus.tokenInvalid", "Token 失效") },
  misconfigured: { tone: "err",  label: "bad config", cta: tr("localStatus.badUrl", "URL 错误") },
  error:         { tone: "err",  label: "error",      cta: tr("localStatus.error", "异常") },
};

// 共享确认弹窗:所有删除统一走这里,不再内联两段式。createPortal 到 body,不受卡片层叠限制。
function ConfirmModal({ open, title, message, confirmLabel, danger, onConfirm, onCancel }) {
  if (!open) return null;
  return createPortal(
    <div data-id="ConfirmModal"
      style={{ position: "fixed", inset: 0, zIndex: 70, display: "flex", alignItems: "center", justifyContent: "center", background: "rgba(0,0,0,.5)" }}
      onMouseDown={(e) => { if (e.target === e.currentTarget) onCancel?.(); }}>
      <div onClick={(e) => e.stopPropagation()}
        style={{ width: 360, maxWidth: "92vw", background: "var(--card, #1b1d22)", border: "1px solid var(--border, #2c2f36)", borderRadius: 14, padding: 22, boxShadow: "0 20px 60px rgba(0,0,0,.5)" }}>
        <div style={{ fontSize: 16, fontWeight: 700, marginBottom: 6 }}>{title}</div>
        {message && <div style={{ fontSize: 13, opacity: .7, marginBottom: 16 }}>{message}</div>}
        <div className="modal-actions">
          <button type="button" className="btn-ghost" onClick={onCancel}>{tr("common.cancel", "取消")}</button>
          <button type="button" className="btn-primary" data-id="ConfirmModal-ok"
            style={danger ? { background: "#dc2626", borderColor: "#dc2626" } : {}}
            onClick={onConfirm}>{confirmLabel || tr("common.confirm", "确认")}</button>
        </div>
      </div>
    </div>,
    document.body,
  );
}

// 私有云 / (历史)云端团队卡片。产品方向变更(w-10032):公有云不做了,主打 private
// (用户自托管,数据不出企业)。private 字段:{name,kind:"private",status,apiKey,
// gatewayUrl,host_url,titleVersion,deviceId:""}。卡片展示名字+host_url,点开可看/复制 apiKey。
function TeamCard({ team, onOpen, onRename, onEditUrl, onDelete, avatar, onAvatar }) {
  const isPrivate = team.kind === "private";
  const statusOk = team.status === "active";
  const serverName = team.name || team.title || "—";
  // Inline rename(和本地/Docker 卡一致):双击标题改名 → onRename 走云端 PATCH。
  const [editing, setEditing] = useState(false);
  const [draft, setDraft] = useState("");
  const [pendingName, setPendingName] = useState(null);
  const name = pendingName != null ? pendingName : serverName;
  useEffect(() => { if (pendingName != null && serverName === pendingName) setPendingName(null); }, [serverName, pendingName]);
  const startEdit = (e) => { e?.stopPropagation?.(); setDraft(name === "—" ? "" : name); setEditing(true); };
  const commitName = async () => {
    setEditing(false);
    const next = String(draft || "").trim();
    if (!onRename || !next || next === name) return;
    setPendingName(next);
    try { const r = await onRename(team.id, next); if (!r?.ok) setPendingName(null); } catch { setPendingName(null); }
  };
  const hostUrl = team.host_url || "";
  // Inline 改私有云地址(host_url)—— 和改名同样的乐观更新:本地先显示 pendingUrl,云端
  // PATCH 成功后 refreshCloudTeams 把真值同步回来,pendingUrl 清空。
  const [editingUrl, setEditingUrl] = useState(false);
  const [urlDraft, setUrlDraft] = useState("");
  const [pendingUrl, setPendingUrl] = useState(null);
  const displayHostUrl = pendingUrl != null ? pendingUrl : hostUrl;
  useEffect(() => { if (pendingUrl != null && hostUrl === pendingUrl) setPendingUrl(null); }, [hostUrl, pendingUrl]);
  const billTeamId = team.teamId || team.id; // /dash?team=<teamId>(URL 不带 key)
  const kindLabel = isPrivate ? tr("teamKind.private", "私有云") : (team.team_kind === "personal" ? tr("teamKind.personal", "个人") : tr("teamKind.shared", "共享"));
  const openUrl = isPrivate ? displayHostUrl : (team.workspace_url || team.workspace_direct_url);
  const hasUrl = !!openUrl;

  // ⋯ menu (reload) — mirrors LocalTeamCard so EVERY card has a 刷新窗口. Reload
  // re-loads the cloud team's tab in profile 0 (opens it if not yet open).
  const [menuOpen, setMenuOpen] = useState(false);
  const [busy, setBusy] = useState(false);
  const [menuPos, setMenuPos] = useState({ top: 0, left: 0 });
  const menuWrap = useRef(null);
  const kebabRef = useRef(null);
  const menuRef = useRef(null);
  const MENU_W = 184;
  const toggleMenu = () => {
    if (!menuOpen && kebabRef.current) {
      const r = kebabRef.current.getBoundingClientRect();
      const left = Math.max(8, Math.min(r.right - MENU_W, window.innerWidth - MENU_W - 8));
      setMenuPos({ top: Math.round(r.bottom + 4), left: Math.round(left) });
    }
    setMenuOpen((v) => !v);
  };
  useEffect(() => {
    if (!menuOpen) return;
    const onDoc = (e) => {
      if (kebabRef.current?.contains(e.target)) return;
      if (menuRef.current?.contains(e.target)) return;
      setMenuOpen(false);
    };
    document.addEventListener("mousedown", onDoc);
    return () => document.removeEventListener("mousedown", onDoc);
  }, [menuOpen]);
  // 刷新窗口:三卡完全同一逻辑——tabs.reloadIfOpen 按 URL 找开着的 tab 就 reload,
  // 没开就不操作(不偷偷开新窗)。
  const doReload = (e) => { e?.stopPropagation?.(); if (!hasUrl) return; setMenuOpen(false); window.cicy?.tabs?.reloadIfOpen?.(openUrl, name); };
  // 删除确认弹窗:私有云 team 删除必须确认,走 ConfirmModal。
  const [confirmDel, setConfirmDel] = useState(false);
  const startEditUrl = () => { setUrlDraft(hostUrl); setEditingUrl(true); setMenuOpen(false); };
  const commitUrl = async () => {
    setEditingUrl(false);
    const next = String(urlDraft || "").trim();
    if (!onEditUrl || !next || next === hostUrl) return;
    setPendingUrl(next);
    try { const r = await onEditUrl(team.id, next); if (!r?.ok) setPendingUrl(null); } catch { setPendingUrl(null); }
  };
  // 私有云卡片不展示 api key(安全)。key 只在云端 dash / 注入 global.json 用。
  return (
    <>
    <div data-id="TeamCard" className={`bcard bcard--cloud${statusOk ? " bcard--online" : ""}`}>
      <div className="bcard__accent" />
      <div className="bcard__top">
        <div className="bcard__pill">
          <span className="bcard__dot" data-tone={statusOk ? "ok" : "off"} />
          <GlobeIcon />
        </div>
        <div className="bcard__top-right">
          {team.is_trial && <span className="bcard__badge">trial</span>}
          {(hasUrl || billTeamId != null || (isPrivate && onEditUrl)) && (
            <div className="bcard__menuwrap" ref={menuWrap} onClick={(e) => e.stopPropagation()}>
              <button
                type="button"
                ref={kebabRef}
                data-id="TeamCard-menu-btn"
                className="bcard__kebab"
                title={tr("localTeams.more", "更多")}
                disabled={busy}
                onClick={toggleMenu}
              >
                {busy ? <Spinner /> : <KebabIcon />}
              </button>
              {menuOpen && createPortal(
                <div className="bcard__menu bcard__menu--portal" data-id="TeamCard-menu" role="menu"
                  ref={menuRef}
                  style={{ position: "fixed", top: menuPos.top, left: menuPos.left, width: MENU_W }}
                  onClick={(e) => e.stopPropagation()}>
                  {hasUrl && (
                    <button type="button" data-id="TeamCard-reload" className="bcard__menu-item" onClick={doReload}>
                      {tr("localTeams.reloadWindow", "刷新窗口")}
                    </button>
                  )}
                  {billTeamId != null && (
                    <button type="button" data-id="TeamCard-billing" className="bcard__menu-item"
                      onClick={(e) => { e.stopPropagation(); setMenuOpen(false); openCloudPage(`?team=${encodeURIComponent(billTeamId)}`); }}>
                      {tr("localTeams.billing", "账单")}
                    </button>
                  )}
                  {isPrivate && onEditUrl && (
                    <button type="button" data-id="TeamCard-edit-url" className="bcard__menu-item"
                      onClick={(e) => { e.stopPropagation(); startEditUrl(); }}>
                      {hostUrl ? tr("teamCard.editUrl", "更改访问地址") : tr("teamCard.setUrl", "填写访问地址")}
                    </button>
                  )}
                  {isPrivate && onDelete && (
                    <button type="button" data-id="TeamCard-delete" className="bcard__menu-item is-danger"
                      onClick={(e) => { e.stopPropagation(); setMenuOpen(false); setConfirmDel(true); }}>
                      {tr("teamCard.delete", "删除")}
                    </button>
                  )}
                </div>,
                document.body,
              )}
            </div>
          )}
        </div>
      </div>
      <div className="bcard__body">
        {/* 固定高度容器:h3 与 input 同高,切换不引起位移 */}
        <div style={{ height: 28, display: "flex", alignItems: "center", gap: 8 }}>
        <TeamAvatar size={24} avatar={avatar} name={team?.name || team?.title} teamId={team?.id} onChanged={onAvatar} />
        {editing ? (
          <input
            data-id="TeamCard-rename-input"
            autoFocus
            value={draft}
            onChange={(e) => setDraft(e.target.value)}
            onFocus={(e) => e.target.select()}
            onBlur={commitName}
            onClick={(e) => e.stopPropagation()}
            onKeyDown={(e) => { if (e.nativeEvent.isComposing || e.keyCode === 229) return; if (e.key === "Enter") commitName(); else if (e.key === "Escape") setEditing(false); }}
            style={{ flex: 1, width: "100%", font: "inherit", fontWeight: 600, padding: "2px 6px", border: "1px solid #3b82f6", borderRadius: 6, background: "#0d1117", color: "#e6edf3", boxSizing: "border-box" }}
          />
        ) : (
          <h3 className="bcard__name" title={onRename ? tr("localTeams.renameHint", "点名字或 ✎ 改名") : name} style={{ display: "flex", alignItems: "center", gap: 6, flex: 1, minWidth: 0, margin: 0 }} onDoubleClick={onRename ? startEdit : undefined}>
            <span onClick={onRename ? startEdit : undefined} style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", cursor: onRename ? "text" : "default" }}>{name}</span>
            {onRename && (
              <button type="button" data-id="TeamCard-rename-btn" title={tr("localTeams.rename", "重命名")} onClick={startEdit} style={{ flex: "none", cursor: "pointer", border: "none", background: "transparent", color: "#8b949e", fontSize: 13, padding: 0, lineHeight: 1 }}>✎</button>
            )}
          </h3>
        )}
        </div>
        <div className="bcard__meta">
          <span className="bcard__chip">{kindLabel}</span>
          {!isPrivate && team.membership_status && team.membership_status !== "active" && (
            <span className="bcard__chip">{team.membership_status}</span>
          )}
        </div>
      </div>
      <button
        type="button"
        className="bcard__cta"
        onClick={onOpen}
        disabled={!hasUrl}
      >
        <ArrowIcon />
        <span>{hasUrl ? tr("localTeams.open", "打开") : (isPrivate ? tr("teamCard.noHost", "未填访问地址") : tr("teamCard.noUrl", "无 URL"))}</span>
      </button>
    </div>
    {editingUrl && createPortal(
      <div data-id="TeamCard-url-modal"
        style={{ position: "fixed", inset: 0, zIndex: 65, display: "flex", alignItems: "center", justifyContent: "center", background: "rgba(0,0,0,.5)" }}
        onMouseDown={(e) => { if (e.target === e.currentTarget) setEditingUrl(false); }}>
        <div onClick={(e) => e.stopPropagation()}
          style={{ width: 400, maxWidth: "92vw", background: "var(--card, #1b1d22)", border: "1px solid var(--border, #2c2f36)", borderRadius: 14, padding: 22, boxShadow: "0 20px 60px rgba(0,0,0,.5)" }}>
          <div style={{ fontSize: 16, fontWeight: 700, marginBottom: 4 }}>{hostUrl ? tr("teamCard.editUrl", "更改访问地址") : tr("teamCard.setUrl", "填写访问地址")}</div>
          <div style={{ fontSize: 12, opacity: .6, marginBottom: 16 }}>{name}</div>
          <textarea data-id="TeamCard-url-input" autoFocus rows={3} className="login-email-input" style={{ width: "100%", resize: "vertical", lineHeight: 1.5, fontFamily: "var(--mono)" }}
            value={urlDraft} placeholder="https://你的私有云地址:端口" spellCheck={false}
            onChange={(e) => setUrlDraft(e.target.value.replace(/[\r\n]+/g, ""))}
            onKeyDown={(e) => { if (e.nativeEvent.isComposing || e.keyCode === 229) return; if (e.key === "Enter") { e.preventDefault(); commitUrl(); } else if (e.key === "Escape") setEditingUrl(false); }} />
          <div className="modal-actions">
            <button type="button" className="btn-ghost" onClick={() => setEditingUrl(false)}>{tr("common.cancel", "取消")}</button>
            <button type="button" className="btn-primary" data-id="TeamCard-url-save" onClick={commitUrl}>{tr("common.save", "保存")}</button>
          </div>
        </div>
      </div>,
      document.body,
    )}
    <ConfirmModal open={confirmDel}
      title={tr("localTeams.deleteTitle", "删除团队")}
      message={tr("localTeams.deleteMsg", "确定删除「{{name}}」?此操作不可撤销。", { name })}
      confirmLabel={tr("common.delete", "删除")} danger
      onConfirm={async () => { setConfirmDel(false); if (onDelete) await onDelete(team); }}
      onCancel={() => setConfirmDel(false)}
    />
    </>
  );
}

function Loading({ text }) {
  return (
    <div className="spinner-row" style={{ padding: "12px 0" }}>
      <Spinner /><span>{text}</span>
    </div>
  );
}

function Brand() {
  return (
    <div className="brand">
      <div className="brand-mark"><BrandGlyph /></div>
      <div className="brand-text">
        <div className="brand-name">CiCy Desktop</div>
        <div className="brand-sub">{tr("app.brandSub", "团队 AI 协作工作台")}</div>
      </div>
    </div>
  );
}

// app 自更新 banner = 入口条:只在「发现新版本」时出现「vX [下载更新]」。点下载后
// 走 updateDrawer(像 Docker 安装那样的抽屉:醒目进度条 + 速度/剩余 + 安装按钮)。
function UpdateBanner() {
  const [st, setSt] = useState(null);
  const [dismissed, setDismissed] = useState(false);
  const [autoUpd, setAutoUpd] = useState(false); // 「以后自动更新到最新版」(存 global.json,设备级)
  const active = useRef(false); // 用户已点下载 → 后续状态喂给 drawer
  const toggleAuto = (on) => {
    setAutoUpd(on);
    window.cicy?.app?.setAutoUpdate?.(on).catch(() => {});
  };
  useEffect(() => {
    let alive = true;
    window.cicy?.app?.updateState?.().then((s) => { if (alive) setSt(s); }).catch(() => {});
    window.cicy?.app?.getAutoUpdate?.().then((v) => { if (alive) setAutoUpd(v === true); }).catch(() => {});
    const unsub = window.cicy?.app?.onUpdateState?.((s) => {
      setSt(s);
      if (typeof s?.autoUpdate === "boolean") setAutoUpd(s.autoUpdate);
      // 主进程自动更新(开关已开):没人点过下载,自己开抽屉显示进度。
      if (s?.auto && !active.current && (s.status === "downloading" || s.status === "ready")) {
        active.current = true;
        setDismissed(true);
        updateDrawer.open({ kind: "app", title: tr("updateBanner.drawerTitle", "应用更新"), fromVer: s.current, toVer: s.version, onRetry: () => window.cicy?.app?.downloadUpdate?.() });
        updateDrawer.push({ phase: "download", status: "running", message: tr("updateBanner.autoDl", "已开启自动更新,正在下载安装包…") });
      }
      if (!active.current) return;
      if (s.status === "downloading") updateDrawer.setProgress(s.progress || {});
      else if (s.status === "ready") updateDrawer.ready({ onInstall: () => window.cicy?.app?.installUpdate?.() });
      else if (s.status === "error") { updateDrawer.finish({ ok: false, message: s.error || tr("updateBanner.error", "更新失败") }); active.current = false; }
    });
    return () => { alive = false; try { unsub && unsub(); } catch {} };
  }, []);
  const status = st?.status;
  useEffect(() => { setDismissed(false); }, [status]);
  // 点「下载更新」→ 开抽屉,把下载/安装放进去(进度条/速度/剩余/安装,像 Docker 安装)。
  const startDownload = () => {
    active.current = true;
    setDismissed(true); // 关弹窗,交给抽屉显示进度
    updateDrawer.open({ kind: "app", title: tr("updateBanner.drawerTitle", "应用更新"), fromVer: st?.current, toVer: st?.version, onRetry: startDownload });
    updateDrawer.push({ phase: "download", status: "running", message: tr("updateBanner.startDl", "开始下载安装包…") });
    updateDrawer.setProgress({ percent: 0 });
    window.cicy?.app?.downloadUpdate?.();
  };
  if (status !== "available" || dismissed) return null; // 仅做入口;下载/安装在抽屉里
  // 强提示:居中模态弹窗(比 banner 醒目),点遮罩/「稍后」可关,下次启动/再检测到再弹。
  return createPortal(
    <div data-id="UpdateBanner"
      style={{ position: "fixed", inset: 0, zIndex: 10000, background: "rgba(0,0,0,0.6)", backdropFilter: "blur(3px)", display: "flex", alignItems: "center", justifyContent: "center" }}
      onMouseDown={(e) => { if (e.target === e.currentTarget) setDismissed(true); }}>
      <div onClick={(e) => e.stopPropagation()}
        style={{ width: 380, maxWidth: "90vw", background: "#161b22", border: "1px solid #30363d", borderRadius: 14, padding: "24px 24px 20px", boxShadow: "0 20px 60px rgba(0,0,0,0.55)", textAlign: "center" }}>
        <div style={{ fontSize: 34, lineHeight: 1, marginBottom: 12 }} aria-hidden>🚀</div>
        <h3 style={{ margin: "0 0 8px", fontSize: 17, color: "#e6edf3" }} data-id="UpdateBanner-text">{tr("updateBanner.available", "发现新版本 v{{v}}", { v: st.version })}</h3>
        <p style={{ margin: "0 0 14px", fontSize: 13, lineHeight: 1.6, color: "#9aa4b2" }}>{tr("updateBanner.modalSub", "建议尽快更新到最新版本")}</p>
        <label data-id="UpdateBanner-auto" style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 7, margin: "0 0 18px", fontSize: 12.5, color: "#c9d1d9", cursor: "pointer", userSelect: "none" }}>
          <input type="checkbox" checked={autoUpd} onChange={(e) => toggleAuto(e.target.checked)} style={{ accentColor: "#238636", width: 15, height: 15, margin: 0, cursor: "pointer" }} />
          {tr("updateBanner.autoNext", "以后发现新版本自动更新，不再询问")}
        </label>
        <div style={{ display: "flex", gap: 10, justifyContent: "center" }}>
          <button type="button" data-id="UpdateBanner-later" onClick={() => setDismissed(true)}
            style={{ flex: 1, padding: "9px 16px", borderRadius: 9, border: "1px solid #30363d", background: "transparent", color: "#c9d1d9", cursor: "pointer", fontSize: 14 }}>
            {tr("updateBanner.later", "稍后")}
          </button>
          <button type="button" data-id="UpdateBanner-download" onClick={startDownload}
            style={{ flex: 1, padding: "9px 16px", borderRadius: 9, border: "none", background: "#238636", color: "white", cursor: "pointer", fontSize: 14, fontWeight: 600 }}>
            {tr("updateBanner.updateNow", "立即更新")}
          </button>
        </div>
      </div>
    </div>,
    document.body
  );
}
// 首次加载的团队卡占位骨架(与 .bcard 大致同形:头像圈 + 两行标题 + CTA 条)。
function SkeletonCard() {
  return (
    <div className="bcard bcard--skeleton" data-id="SkeletonCard" aria-hidden>
      <div className="bcard__accent" style={{ background: "rgba(255,255,255,.08)", opacity: 1 }} />
      {/* 对齐真实卡片:顶部 pill + kebab */}
      <div className="bcard__top">
        <div className="skel" style={{ width: 84, height: 26, borderRadius: 999 }} />
        <div className="skel" style={{ width: 22, height: 22, borderRadius: 6 }} />
      </div>
      {/* body:方头像(24)+ 名字行,下面 meta chip;flex:1 把 CTA 顶到底 */}
      <div style={{ flex: 1, minHeight: 0, marginTop: 14 }}>
        <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
          <div className="skel" style={{ width: 24, height: 24, borderRadius: 6, flex: "0 0 auto" }} />
          <div className="skel skel--line" style={{ width: "55%" }} />
        </div>
        <div className="skel" style={{ width: 60, height: 18, borderRadius: 6, marginTop: 12 }} />
      </div>
      <div className="skel skel--cta" style={{ marginTop: 0 }} />
    </div>
  );
}
// 团队头像:有自定义图(data URL)就显示图,否则「团队名首字母 + 按名 hash 的稳定底色」
// 圆角块。teamId 存在时点击可上传(resize 在主进程做,见 local-teams.setAvatar)。
// 同一份用于卡片头像 + tab icon(tab 那边在 tab-shell.html faviconNode 用 t.avatar)。
// Avatar 底色:一组**明显区分**的离散色板,而不是连续色相(连续色相会落到糊在一起
// 的邻近色,如 289° 紫 vs 314° 洋红)。12 个拉开 ≥24° 的色相 + 交替明度,不同 team 一眼
// 可分,白字始终清晰。key 必须 String —— 云端 teamId 是数字(71),数字没 .length,循环
// 不跑 → 恒 0 → 全同色。
const AVATAR_PALETTE = [
  "hsl(2 68% 48%)", "hsl(26 72% 46%)", "hsl(45 70% 42%)", "hsl(96 55% 38%)",
  "hsl(140 58% 40%)", "hsl(168 62% 36%)", "hsl(192 70% 42%)", "hsl(210 72% 50%)",
  "hsl(232 62% 56%)", "hsl(266 55% 55%)", "hsl(292 58% 50%)", "hsl(324 66% 50%)",
];
function hashStr(s) { s = String(s == null ? "" : s); let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) % 100003; return h; }
function avatarBg(key) { return AVATAR_PALETTE[hashStr(key) % AVATAR_PALETTE.length]; }
// 容器内使用 Docker(Docker-outside-of-Docker)modal —— 单 checkbox,像 CftModal 一样渲染在
// 卡片层(菜单外)。开启 → sidecar.setDood → 重建容器挂 docker.sock + 把 docker CLI 装进容器
// 持久卷,下载进度实时显示在弹窗里。DockerCard(Windows 容器)用。
function DoodModal({ open, onClose, toastId = "dood-op" }) {
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState("");
  const [on, setOn] = useState(false);
  const [line, setLine] = useState("");
  useEffect(() => {
    if (!open || !window.cicy?.sidecar?.getDood) return;
    setErr(""); setLine("");
    window.cicy.sidecar.getDood().then((r) => setOn(!!r?.dood)).catch(() => {});
  }, [open]);
  // 把重建/下载进度实时显示在弹窗(installDockerCli 的每行输出经 op-progress 推来)
  useEffect(() => {
    if (!open || !busy || !window.cicy?.sidecar?.onOpProgress) return;
    return window.cicy.sidecar.onOpProgress((ev) => { if (ev?.message) setLine(String(ev.message)); });
  }, [open, busy]);
  const save = async () => {
    if (busy) return;
    setBusy(true); setErr(""); setLine("");
    toast.show({ id: toastId, message: on ? tr("dood.applying", "开启容器 Docker 访问,重建容器中…") : tr("dood.disabling", "关闭中…"), status: "running", progress: undefined });
    try {
      const r = await window.cicy.sidecar.setDood(on);
      if (r?.ok) {
        onClose && onClose();
        toast.show({ id: toastId, message: on ? tr("dood.onDone", "容器 Docker 访问已开启") : tr("dood.offDone", "容器 Docker 访问已关闭"), status: "done", ttl: 4000 });
      } else {
        setErr(r?.error || tr("dood.failed", "设置失败"));
        toast.show({ id: toastId, message: tr("dood.failed", "设置失败") + (r?.error ? `: ${r.error}` : ""), status: "error", ttl: 6000 });
      }
    } catch (e) { setErr(e?.message || String(e)); toast.show({ id: toastId, message: tr("dood.failed", "设置失败") + `: ${e?.message || e}`, status: "error", ttl: 6000 }); }
    finally { setBusy(false); }
  };
  const setOpenX = (v) => { if (!v) onClose && onClose(); };
  if (!open) return null;
  return (
    <>
      {createPortal(
        <div data-id="dood-modal"
          style={{ position: "fixed", inset: 0, zIndex: 66, display: "flex", alignItems: "center", justifyContent: "center", background: "rgba(0,0,0,.5)" }}
          onMouseDown={(e) => { if (!busy && e.target === e.currentTarget) setOpenX(false); }}>
          <div onClick={(e) => e.stopPropagation()}
            style={{ width: 440, maxWidth: "92vw", background: "var(--card, #1b1d22)", border: "1px solid var(--border, #2c2f36)", borderRadius: 14, padding: 22, boxShadow: "0 20px 60px rgba(0,0,0,.5)" }}>
            <div style={{ fontSize: 16, fontWeight: 700, marginBottom: 4 }}>{tr("dood.title", "容器内使用 Docker")}</div>
            <div style={{ fontSize: 12, opacity: .6, marginBottom: 16 }}>{tr("dood.subtitle", "把宿主 Docker + docker 客户端挂进容器,容器内 agent 就能直接跑 docker(秒生效,无需下载)")}</div>
            <label data-id="dood-toggle-row" style={{ display: "flex", alignItems: "center", gap: 10, cursor: busy ? "default" : "pointer", marginBottom: 12 }}>
              <input type="checkbox" data-id="dood-toggle" checked={on} disabled={busy}
                onChange={(e) => setOn(e.target.checked)} />
              <span style={{ fontSize: 14, fontWeight: 600 }}>{tr("dood.enable", "允许容器内使用 Docker")}</span>
            </label>
            {busy && line && <div data-id="dood-progress" style={{ fontSize: 11, opacity: .7, fontFamily: "var(--mono)", wordBreak: "break-all", margin: "0 0 8px", maxHeight: 60, overflow: "hidden" }}>{line}</div>}
            {err && <div className="error" style={{ marginTop: 8, fontSize: 12 }}>{err}</div>}
            <div className="modal-actions">
              <button type="button" className="btn-ghost" disabled={busy} onClick={() => setOpenX(false)}>{tr("common.cancel", "取消")}</button>
              <button type="button" className="btn-primary" data-id="dood-save" disabled={busy} onClick={save}>{busy ? tr("common.saving", "保存中…") : tr("common.save", "保存")}</button>
            </div>
          </div>
        </div>,
        document.body,
      )}
    </>
  );
}
function TeamAvatar({ avatar, name, teamId, onChanged, size = 34 }) {
  const fileRef = useRef(null);
  const initial = ((name || "?").trim()[0] || "?").toUpperCase();
  // 底色按**唯一的 teamId** 算(默认名都本地化成同一个「Local team」,按名字会同色)。
  const bg = avatarBg(teamId || name || "");
  const pick = (e) => {
    const f = e.target.files && e.target.files[0];
    e.target.value = "";
    if (!f) return;
    const r = new FileReader();
    r.onload = async () => { try { await window.cicy?.localTeams?.setAvatar?.(teamId, r.result); onChanged && onChanged(); } catch (_) {} };
    r.readAsDataURL(f);
  };
  return (
    <div data-id="TeamAvatar" className="team-avatar"
      title={teamId ? tr("teamCard.changeAvatar", "点击更换头像") : ""}
      onClick={teamId ? (e) => { e.stopPropagation(); fileRef.current && fileRef.current.click(); } : undefined}
      style={{ width: size, height: size, borderRadius: 9, flex: "none", overflow: "hidden", display: "flex", alignItems: "center", justifyContent: "center", cursor: teamId ? "pointer" : "default", background: avatar ? "#0d1117" : bg, color: "#fff", fontWeight: 700, fontSize: Math.round(size * 0.42), userSelect: "none" }}>
      {avatar ? <img src={avatar} alt="" style={{ width: "100%", height: "100%", objectFit: "cover" }} /> : initial}
      {teamId && <input ref={fileRef} type="file" accept="image/*" style={{ display: "none" }} onChange={pick} />}
    </div>
  );
}
function BrandGlyph() {
  // CiCy outline mark. Rendered white because it sits on the brand chip's
  // blue→violet gradient square.
  return (
    <svg width="22" height="22" viewBox="0 0 96 96" fill="none">
      <path d="M48 11L39.5 33.3L16 29.5L31 48L16 66.5L39.5 62.7L48 85L56.5 62.7L80 66.5L65 48L80 29.5L56.5 33.3Z"
        stroke="white" strokeWidth="10" strokeLinejoin="round" strokeLinecap="round" />
      <circle cx="48" cy="48" r="6" fill="white" />
    </svg>
  );
}
function ArrowIcon() {
  return (
    <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
      <line x1="5" y1="12" x2="19" y2="12" />
      <polyline points="12 5 19 12 12 19" />
    </svg>
  );
}
function Spinner() {
  return (
    <svg className="spin" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round">
      <path d="M21 12a9 9 0 1 1-6.2-8.55" />
    </svg>
  );
}
function LaptopIcon() {
  return (
    <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
      <rect x="3" y="4" width="18" height="12" rx="2" />
      <line x1="2" y1="20" x2="22" y2="20" />
    </svg>
  );
}
function RefreshIcon() {
  return (
    <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
      <path d="M21 12a9 9 0 1 1-2.64-6.36" />
      <polyline points="21 3 21 9 15 9" />
    </svg>
  );
}
function KebabIcon() {
  return (
    <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
      <circle cx="12" cy="5" r="1.7" />
      <circle cx="12" cy="12" r="1.7" />
      <circle cx="12" cy="19" r="1.7" />
    </svg>
  );
}
function GlobeIcon() {
  return (
    <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
      <circle cx="12" cy="12" r="10" />
      <line x1="2" y1="12" x2="22" y2="12" />
      <path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
    </svg>
  );
}

function safeGet(k) {
  try { return localStorage.getItem(k) || null; } catch { return null; }
}

function humanError(s) {
  if (!s) return "";
  if (/timeout/i.test(s))         return "登录超时，请重新点击 Login。";
  if (/state/i.test(s))           return "校验失败，请重新登录。";
  if (/no token/i.test(s))        return "登录未完成，请重试。";
  if (/bridge missing/i.test(s))  return "无法连接到登录服务（preload 未就绪）。";
  return s;
}


// ─── CiCy Hub ────────────────────────────────────────────────────────────────
// Sign in to the hub with an email (6-digit code or the link in the mail) and the
// homepage lists every cicy-code instance of that account — one card per node,
// online/offline, version, live cpu/mem/disk — without a local cicy-code and
// without adding each node as a custom team. Opening goes through a one-time
// hub grant, so the tab lands logged in.
const HUB_REFRESH_MS = 30_000;

function hubErrText(code) {
  const c = String(code || "");
  const map = {
    invalid_code: tr("cicyHub.codeInvalid", "验证码不对或已过期"),
    rate_limited: tr("cicyHub.rateLimited", "发送太频繁,稍后再试(每小时最多 5 次)"),
    mail_failed: tr("cicyHub.mailFailed", "邮件发送失败"),
    name_taken: tr("cicyHub.nameTaken", "名称冲突,换台机器再试"),
    expired: tr("cicyHub.expired", "登录已过期,重新发送"),
    invalid_email: tr("auth.badEmail", "请输入有效的邮箱地址"),
  };
  return map[c] || c;
}

function useHub() {
  const bridge = typeof window !== "undefined" ? window.cicy?.hub : null;
  const [status, setStatus] = useState(null);       // { loggedIn, owner, pending }
  const [instances, setInstances] = useState(null); // null = not loaded
  const [takenNames, setTakenNames] = useState([]);  // 全部已占用名字(含 desktop-*),改名查重用
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState("");
  const [loginOpen, setLoginOpen] = useState(false);
  const [email, setEmail] = useState(DEFAULT_EMAIL);
  const [code, setCode] = useState("");
  const [step, setStep] = useState("email");         // email | code
  // Signing in without saying which machine this is leaves another anonymous
  // box that can only be addressed by guessing. Required, alongside the email.
  const [team, setTeam] = useState(null);            // null = still reading it
  useEffect(() => { let on = true; readTeam().then((t) => on && setTeam(t || "")); return () => { on = false; }; }, []);
  const [busy, setBusy] = useState(false);
  const [loginErr, setLoginErr] = useState("");

  const refreshStatus = useCallback(async () => {
    if (!bridge) return null;
    try { const st = await bridge.status(); setStatus(st || null); return st; } catch { return null; }
  }, [bridge]);

  const refresh = useCallback(async () => {
    if (!bridge) return;
    setLoading(true);
    try {
      const r = await bridge.instances();
      if (r?.ok) {
        // Hide the desktop pseudo-instances (name starts with "desktop-"): they
        // are the machines enrolled onto the control channel, not openable
        // cicy-code teams, so a card for one only misleads.
        const only = (r.instances || []).filter((it) => !/^desktop-/i.test(String((it && (it.name || it.slug)) || "")));
        setInstances(only);
        // 改名查重要用的是**全部**名字(包含上面被过滤掉的 desktop-* 机器),
        // 否则改成一个已存在的机器名,车队里就会有两台同名机器,分不清也点不准。
        setTakenNames((r.instances || []).map((it) => String((it && (it.name || it.slug)) || "").trim()).filter(Boolean));
        setError("");
      }
      else {
        setInstances([]);
        setError(r?.error || "");
        if (r?.error === "unauthorized" || r?.error === "not_logged_in") refreshStatus();
      }
    } catch (e) { setError(e?.message || String(e)); }
    finally { setLoading(false); }
  }, [bridge, refreshStatus]);

  // mount: status → instances; periodic refresh while signed in
  useEffect(() => {
    if (!bridge) { setStatus({ loggedIn: false }); setInstances([]); return; }
    let alive = true;
    refreshStatus().then((st) => { if (alive && st?.loggedIn) refresh(); else if (alive) setInstances([]); });
    const off = bridge.onComplete?.((p) => {
      if (p?.ok) {
        setLoginOpen(false); setBusy(false); setStep("email"); setCode("");
        toast.show({ id: "hub-login", message: tr("auth.loginSuccess", "登录成功"), status: "done", ttl: 2500 });
        refreshStatus().then(() => refresh());
      } else if (p?.error && p.error !== "cancelled") {
        setBusy(false); setLoginErr(hubErrText(p.error)); setStep("email");
      }
    });
    return () => { alive = false; try { off && off(); } catch {} };
  }, [bridge, refresh, refreshStatus]);
  useEffect(() => {
    if (!status?.loggedIn) return;
    const id = setInterval(refresh, HUB_REFRESH_MS);
    return () => clearInterval(id);
  }, [status?.loggedIn, refresh]);

  const openLogin = (prefill) => { setLoginErr(""); setStep("email"); setCode(""); if (prefill && !email.trim()) setEmail(String(prefill)); setLoginOpen(true); };
  const closeLogin = () => { if (step === "code") { try { bridge?.cancel(); } catch {} } setLoginOpen(false); setBusy(false); };
  const sendCode = async () => {
    const addr = email.trim();
    if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(addr)) { setLoginErr(tr("auth.badEmail", "请输入有效的邮箱地址")); return; }
    // 同上:team 名可留空,登录后在账号菜单里改。留空不写,避免清掉已有名字。
    const declared = String(team || "").trim();
    if (declared && !TEAM_RE.test(declared)) { setLoginErr(tr("cicyHub.teamBadFormat", "team 名只能用字母、数字和 . _ -")); return; }
    // 查重:别让两台机器叫同一个名字(登录时也拦一道,不然车队里立刻多一台重名的)
    if (declared) {
      const want = withTeamPrefix(declared).toLowerCase();
      // 同上:只和机器名(desktop-*)比,别拿 cicy-code 实例名当重名
      if ((takenNames || []).some((n) => { const t = String(n || "").trim().toLowerCase(); return t.startsWith(TEAM_PREFIX) && t === want; })) {
        setLoginErr(tr("cicyHub.teamTaken", "「" + declared + "」已经被另一台机器用了,换一个")); return;
      }
    }
    setBusy(true); setLoginErr("");
    if (declared) {
      try { await writeTeam(declared); } catch { setLoginErr(tr("cicyHub.teamSaveFailed", "无法保存 team 名")); setBusy(false); return; }
    }
    try { window.__cicyFleetRelabel && window.__cicyFleetRelabel(); } catch {}
    try {
      const r = await bridge.loginStart(addr);
      if (!r?.ok) { setLoginErr(hubErrText(r?.error) || tr("cicyHub.loginFailed", "登录失败")); return; }
      setStep("code");
    } catch (e) { setLoginErr(e?.message || String(e)); }
    finally { setBusy(false); }
  };
  const verify = async () => {
    const c = code.replace(/\D/g, "");
    if (c.length !== 6) { setLoginErr(tr("cicyHub.codeInvalid", "验证码不对或已过期")); return; }
    setBusy(true); setLoginErr("");
    try {
      const r = await bridge.loginCode(c);
      if (!r?.ok) { setLoginErr(hubErrText(r?.error) || tr("cicyHub.loginFailed", "登录失败")); setBusy(false); }
      // success → onComplete closes the modal
    } catch (e) { setLoginErr(e?.message || String(e)); setBusy(false); }
  };
  const logout = async () => {
    try { await bridge?.logout(); } catch {}
    setInstances([]); setError("");
    refreshStatus();
  };
  // `next`: a path inside the node's UI ("/#/project/<slug>", "/#/agent/<wid>").
  const open = async (inst, next, title) => {
    try {
      const r = await bridge.open(inst.id, title || inst.name, 0, next || "/");
      if (!r?.ok) toast.show({ id: "hub-open", message: hubErrText(r?.error) || tr("cicyHub.loginFailed", "打开失败"), status: "error", ttl: 5000 });
    } catch (e) { toast.show({ id: "hub-open", message: e?.message || String(e), status: "error", ttl: 5000 }); }
  };

  return {
    available: !!bridge, status, loggedIn: !!status?.loggedIn, owner: status?.owner || "",
    instances, takenNames, loading, error, refresh, logout, open,
    loginOpen, openLogin, closeLogin, email, setEmail, code, setCode, step, busy, loginErr, sendCode, verify,
    team, setTeam,
  };
}

function HubLoginModal({ hub }) {
  const codeStep = hub.step === "code";
  return createPortal(
    // No click-outside close: the click that re-focuses the window after copying
    // the code from the mail client lands on this backdrop. Cancel is explicit.
    <div data-id="HubLoginModal" style={{ position: "fixed", inset: 0, zIndex: 60, display: "flex", alignItems: "center", justifyContent: "center", background: "rgba(0,0,0,.5)" }}>
      <div onClick={(e) => e.stopPropagation()} style={{ width: 400, maxWidth: "92vw", background: "var(--card, #1b1d22)", border: "1px solid var(--border, #2c2f36)", borderRadius: 14, padding: 22, boxShadow: "0 20px 60px rgba(0,0,0,.5)" }}>
        <div style={{ fontSize: 16, fontWeight: 700, marginBottom: 4 }}>{tr("cicyHub.login", "登录 CiCy Hub")}</div>
        <div style={{ fontSize: 12, opacity: .6, marginBottom: 16 }}>
          {codeStep ? tr("cicyHub.codeSent", "验证码已发到 {{email}},也可以直接点邮件里的链接", { email: hub.email.trim() })
                    : tr("cicyHub.loginSub", "填写本机 team 名 + 邮箱验证码登录。team 名是这台机器的身份,主机名不作数")}
        </div>
        {!codeStep ? (
          <>
            <label style={{ display: "block", fontSize: 12, opacity: .75, marginBottom: 6 }}>{tr("cicyHub.team", "本机 team 名(可选)")}</label>
            <div style={{ display: "flex", alignItems: "center", marginBottom: 12 }}>
              <span style={{ fontSize: 13, opacity: .55, paddingRight: 6, whiteSpace: "nowrap" }}>{TEAM_PREFIX}</span>
              <input data-id="HubLoginModal-team" className="login-email-input" style={{ flex: 1, marginBottom: 0 }} type="text" autoFocus spellCheck={false}
                value={(hub.team || "").replace(TEAM_PREFIX, "")} onChange={(e) => hub.setTeam(e.target.value)} placeholder={tr("cicyHub.teamPlaceholder", "例如 xs-1001")}
                onKeyDown={(e) => { if (e.key === "Enter") hub.sendCode(); }} />
            </div>
            <label style={{ display: "block", fontSize: 12, opacity: .75, marginBottom: 6 }}>{tr("cicyHub.email", "邮箱")}</label>
            <input data-id="HubLoginModal-email" className="login-email-input" style={{ width: "100%", marginBottom: 6 }} type="email" spellCheck={false}
              value={hub.email} onChange={(e) => hub.setEmail(e.target.value)} placeholder="you@example.com"
              onKeyDown={(e) => { if (e.key === "Enter") hub.sendCode(); }} />
          </>
        ) : (
          <>
            <label style={{ display: "block", fontSize: 12, opacity: .75, marginBottom: 6 }}>{tr("cicyHub.code", "6 位验证码")}</label>
            <input data-id="HubLoginModal-code" className="login-email-input" style={{ width: "100%", marginBottom: 6, letterSpacing: 6, fontSize: 20, textAlign: "center" }} inputMode="numeric" autoFocus maxLength={6}
              value={hub.code} onChange={(e) => hub.setCode(e.target.value.replace(/\D/g, "").slice(0, 6))} placeholder="••••••"
              onKeyDown={(e) => { if (e.key === "Enter") hub.verify(); }} />
            {hub.busy && <div style={{ fontSize: 12, opacity: .6, display: "flex", alignItems: "center", gap: 6 }}><Spinner /> {tr("cicyHub.verifying", "验证中…")}</div>}
          </>
        )}
        {hub.loginErr && <div className="error" style={{ marginTop: 10 }}>{hub.loginErr}</div>}
        <div className="modal-actions">
          <button type="button" className="btn-ghost" data-id="HubLoginModal-cancel" onClick={hub.closeLogin}>{tr("cicyHub.cancel", "取消")}</button>
          {!codeStep ? (
            <button type="button" className="btn-primary" data-id="HubLoginModal-send" disabled={hub.busy} onClick={hub.sendCode}>
              {hub.busy ? tr("cicyHub.sending", "发送中…") : tr("cicyHub.sendCode", "发送验证码")}
            </button>
          ) : (
            <button type="button" className="btn-primary" data-id="HubLoginModal-verify" disabled={hub.busy || hub.code.length !== 6} onClick={hub.verify}>
              {hub.busy ? tr("cicyHub.verifying", "验证中…") : tr("cicyHub.verify", "登录")}
            </button>
          )}
        </div>
      </div>
    </div>,
    document.body
  );
}

function hubPct(v) { const n = Number(v); return Number.isFinite(n) ? Math.round(n) + "%" : "–"; }
function hubGB(bytes) { const n = Number(bytes); return Number.isFinite(n) && n > 0 ? (n / 1073741824).toFixed(n >= 100 * 1073741824 ? 0 : 1) + "G" : ""; }

// Agent liveness colour for the drill-down rows.
function hubAgentTone(a) {
  const st = String(a.status || "").toLowerCase();
  if (a.working || st === "working" || st === "busy" || st === "running") return "#3b82f6";
  if (st === "error" || st === "failed") return "#f87171";
  if (st === "active" || st === "idle" || st === "online" || a.online) return "#4ade80";
  return "#6b7280";
}

// Projects → agents of one node, loaded lazily from the node itself when the
// card is expanded. Project → opens the node at #/project/<slug>; agent → at
// #/agent/<wid> (the node's own chat, voice bar included).
function HubInstanceTree({ inst, onOpen }) {
  const [tree, setTree] = useState(null); // null = loading | { error } | { projects }
  useEffect(() => {
    let alive = true;
    (async () => {
      try {
        const r = await window.cicy?.hub?.projects?.(inst.id);
        if (!alive) return;
        setTree(r && r.ok ? { projects: r.projects || [] } : { error: (r && r.error) || "failed" });
      } catch (e) { if (alive) setTree({ error: e?.message || String(e) }); }
    })();
    return () => { alive = false; };
  }, [inst.id]);
  if (!tree) return <div data-id="HubInstanceTree" style={{ padding: "6px 0", fontSize: 12, opacity: .6, display: "flex", alignItems: "center", gap: 6 }}><Spinner /> {tr("cicyHub.loadingProjects", "读取项目…")}</div>;
  if (tree.error) return <div data-id="HubInstanceTree" className="error" style={{ fontSize: 12 }}>{hubErrText(tree.error)}</div>;
  if (!tree.projects.length) return <div data-id="HubInstanceTree" style={{ padding: "6px 0", fontSize: 12, opacity: .6 }}>{tr("cicyHub.noAgents", "这台机器还没有 Agent")}</div>;
  const rowStyle = { display: "flex", alignItems: "center", gap: 6, padding: "4px 6px", borderRadius: 6, cursor: "pointer", fontSize: 12, minWidth: 0 };
  return (
    <div data-id="HubInstanceTree" style={{ display: "flex", flexDirection: "column", gap: 2, marginTop: 6, maxHeight: 260, overflowY: "auto" }}>
      {tree.projects.map((p) => (
        <div key={p.id} data-id="HubProject">
          <div className="bcard__menu-item" style={{ ...rowStyle, fontWeight: 600 }} title={tr("cicyHub.openProject", "打开项目")}
            onClick={(e) => { e.stopPropagation(); onOpen(p.slug ? `/#/project/${encodeURIComponent(p.slug)}` : "/", p.name || inst.name); }}>
            <span style={{ opacity: .7 }}>📁</span>
            <span style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{p.name || tr("cicyHub.ungrouped", "未分组")}</span>
            <span style={{ opacity: .5 }}>{tr("cicyHub.agents", "{{n}} 个 Agent", { n: p.agents.length })}</span>
          </div>
          {p.agents.map((a) => (
            <div key={a.wid} data-id="HubAgent" className="bcard__menu-item" style={{ ...rowStyle, paddingLeft: 22 }} title={`${a.wid}${a.workspace ? " · " + a.workspace : ""}`}
              onClick={(e) => { e.stopPropagation(); onOpen(`/#/agent/${encodeURIComponent(a.wid)}`, `${a.title || a.wid} · ${inst.name}`); }}>
              <span style={{ width: 7, height: 7, borderRadius: 4, background: hubAgentTone(a), flex: "none" }} />
              <span style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{a.title || a.wid}</span>
              {a.role === "master" && <span className="bcard__chip" style={{ fontSize: 10 }}>master</span>}
              {a.agentType && <span style={{ opacity: .5, fontSize: 10 }}>{a.agentType}</span>}
            </div>
          ))}
        </div>
      ))}
    </div>
  );
}

function HubInstanceCard({ inst, onOpen }) {
  const tone = inst.online ? "ok" : "off";
  const res = inst.resources || null;
  const [busy, setBusy] = useState(false);
  const [expanded, setExpanded] = useState(false);
  const handleOpen = async (next, title) => { if (busy) return; setBusy(true); try { await onOpen(next, title); } finally { setBusy(false); } };
  const canOpen = inst.reachable || inst.online;
  const hot = (v) => Number(v) >= 90;
  // One muted line for live usage — chips only for identity (version).
  const usage = res ? [
    { k: "cpu", t: "CPU " + hubPct(res.cpu_usage_pct), warn: hot(res.cpu_usage_pct) },
    { k: "mem", t: "内存 " + hubPct(res.mem_usage_pct), warn: hot(res.mem_usage_pct) },
    { k: "disk", t: "磁盘 " + hubPct(res.disk_usage_pct), warn: hot(res.disk_usage_pct) },
  ] : [];
  const usageTitle = res ? `内存 ${hubGB(res.mem_used_bytes)}/${hubGB(res.mem_total_bytes)} · 磁盘 ${hubGB(res.disk_used_bytes)}/${hubGB(res.disk_total_bytes)}` : "";
  return (
    <div data-id="HubInstanceCard" className={`bcard bcard--custom${inst.online ? " bcard--online" : ""}`} title={inst.host}>
      <div className="bcard__accent" />
      <div className="bcard__top">
        <div className="bcard__pill">
          <span className="bcard__dot" data-tone={tone} />
          <LaptopIcon />
        </div>
        {typeof inst.agents === "number" && inst.agents > 0 && (
          <button type="button" data-id="HubInstanceCard-agents" className="btn-ghost" disabled={!canOpen}
            style={{ fontSize: 11, color: "#8b949e", padding: "2px 6px", border: "none", background: "transparent", cursor: "pointer" }}
            title={tr("cicyHub.toggleTree", "展开 / 收起项目与 Agent")}
            onClick={(e) => { e.stopPropagation(); setExpanded((v) => !v); }}>
            {tr("cicyHub.agents", "{{n}} 个 Agent", { n: inst.agents })} {expanded ? "▾" : "▸"}
          </button>
        )}
      </div>
      <div className="bcard__body">
        <div style={{ height: 28, display: "flex", alignItems: "center", gap: 8 }}>
          <TeamAvatar size={24} name={inst.name} teamId={inst.id} />
          <h3 className="bcard__name" style={{ flex: 1, minWidth: 0, margin: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{inst.name}</h3>
        </div>
        <div className="bcard__meta">
          <span className="bcard__chip" data-id="HubInstanceCard-kind">{tr("cicyHub.kind", "Hub")}</span>
          {inst.version && <span className="bcard__chip" data-id="HubInstanceCard-version">v{inst.version}</span>}
        </div>
        {usage.length > 0 && (
          <div data-id="HubInstanceCard-usage" title={usageTitle} style={{ marginTop: 6, fontSize: 11, color: "#8b949e", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
            {usage.map((u, i) => (
              <span key={u.k} style={u.warn ? { color: "#f87171" } : undefined}>{i > 0 ? " · " : ""}{u.t}</span>
            ))}
          </div>
        )}
        {expanded && canOpen && <HubInstanceTree inst={inst} onOpen={handleOpen} />}
      </div>
      <button type="button" className="bcard__cta" data-id="HubInstanceCard-open" disabled={busy || !canOpen} onClick={() => handleOpen("/", inst.name)}>
        {busy ? <Spinner /> : <ArrowIcon />}
        <span>{canOpen ? tr("cicyHub.open", "打开") : tr("cicyHub.unreachable", "不可达")}</span>
      </button>
    </div>
  );
}
