// Playground root — wires the design's three-column shell to the real
// Baizhi backend.
//
// Boot sequence:
//   1. App mounts in a "loading" state (renders a small splash).
//   2. useEffect loads skills first, then agents/mcps in parallel. Agent
//      decoration needs window.SKILLS to translate backend skill_ids.
//   3. On resolve we set React state AND mirror onto window.SKILLS/MCPS/
//      INITIAL_AGENTS so child components that read those globals at render
//      time (per the original design) see real data.
//   4. App becomes interactive.
//
// Run flow:
//   send() → enqueue + push placeholder UI rows → startAgentRun (api-client)
//   drives polling and dispatches translated events back through callbacks
//   that mutate the same React state slots the mock used (`runs`, `messages`).

const { useState, useEffect, useRef, useMemo, useCallback } = React;

// ----------- column resizer -----------
// A 5px hit-zone between the three panels. Mouse drag resizes the adjacent
// column by writing to CSS variables on `.cols`. Widths are clamped so the
// middle (chat) column never collapses below `MIN_MID_W`, and persisted to
// localStorage so the user's layout survives reload.
const LEFT_BOUNDS = { min: 200, max: 520 };
const RIGHT_BOUNDS = { min: 260, max: 640 };
const MIN_MID_W = 360;

function mergeModelIOCalls(calls, incoming) {
  if (!incoming?.call_id) return calls || [];
  const next = [...(calls || [])];
  const idx = next.findIndex((c) => c.call_id === incoming.call_id);
  const previous = idx >= 0 ? next[idx] : {};
  const normalized = {
    ...previous,
    ...incoming,
    input: incoming.input ?? previous.input ?? {},
    output: incoming.output ?? previous.output ?? {},
    usage: incoming.usage ?? previous.usage ?? {},
  };
  if (idx >= 0) next[idx] = normalized;
  else next.push(normalized);
  next.sort((a, b) => {
    const roundDelta = (a.round_index || 0) - (b.round_index || 0);
    if (roundDelta) return roundDelta;
    return (a.started_at_ms || 0) - (b.started_at_ms || 0);
  });
  return next;
}

function numericUsageValue(...values) {
  for (const value of values) {
    if (value === null || value === undefined || value === "") continue;
    const number = Number(value);
    if (Number.isFinite(number)) return number;
  }
  return null;
}

function catalogTurnCost(model, inputTokens, outputTokens) {
  const pricing = (window.MODELS || []).find((item) => item.id === model)?.pricing;
  if (!pricing || inputTokens === null || outputTokens === null) return null;
  const inputRate = Number(pricing.input_per_million);
  const outputRate = Number(pricing.output_per_million);
  if (!Number.isFinite(inputRate) || !Number.isFinite(outputRate)) return null;
  return (inputTokens * inputRate + outputTokens * outputRate) / 1_000_000;
}

function llmTimelineStep(payload, { model = null, usage = {} } = {}) {
  const inputTokens = numericUsageValue(
    payload?.input_tokens, usage?.input_tokens, usage?.prompt_token_count, usage?.prompt_tokens,
  );
  const outputTokens = numericUsageValue(
    payload?.output_tokens, usage?.output_tokens, usage?.candidates_token_count, usage?.completion_tokens,
  );
  const resolvedModel = payload?.model || model;
  const costUsd = numericUsageValue(payload?.cost_usd, usage?.cost_usd)
    ?? catalogTurnCost(resolvedModel, inputTokens, outputTokens);
  return {
    kind: "llm",
    label: `LLM · round ${payload?.round_index || "?"}${resolvedModel ? ` (${resolvedModel})` : ""}`,
    durMs: payload?.duration_ms || 1,
    inputTokens,
    outputTokens,
    costUsd,
  };
}

function eventStreamAuthQuery() {
  let tok = null;
  try { tok = localStorage.getItem("baizhi.auth.token"); } catch {}
  if (tok) return `token=${encodeURIComponent(tok)}`;
  if (!location.pathname.startsWith("/login")) location.replace("/login");
  return "token=";
}

function trimRunContext(context) {
  const out = {};
  for (const [key, value] of Object.entries(context || {})) {
    const normalizedKey = String(key || "").trim();
    const normalizedValue = String(value || "").trim();
    if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(normalizedKey) || !normalizedValue) continue;
    out[normalizedKey] = normalizedValue;
  }
  return out;
}

function reasoningFromRunEvents(events) {
  let active = false;
  let id = null;
  let content = "";
  for (const ev of events || []) {
    const payload = ev.payload || {};
    if (ev.type === "thinking.started") {
      active = true;
      id = payload.thinking_id || id;
    } else if (ev.type === "thinking.delta") {
      id = payload.thinking_id || id;
      content += payload.delta || "";
    } else if (ev.type === "thinking.completed") {
      active = false;
      id = payload.thinking_id || id;
      content = payload.text || content;
    }
  }
  return active || content.trim() ? { active, id, content } : null;
}

function roundOfLlmId(id) {
  const m = /llm_(\d+)/.exec(id || "");
  return m ? parseInt(m[1], 10) : null;
}

function toolInvocationsFromRunEvents(events) {
  const starts = new Map();
  const ordered = [];
  const seenAgUi = new Set();
  const ensureInvocation = (id, name = "") => {
    const key = id || `${name || "tool"}_${ordered.length + 1}`;
    let inv = starts.get(key);
    if (!inv) {
      inv = {
        toolId: name || "tool",
        name: name || "tool",
        args: {},
        durMs: 0,
        preview: "running...",
        running: true,
        round: null,
      };
      starts.set(key, inv);
      ordered.push(inv);
    }
    if (name && (!inv.name || inv.name === "tool")) {
      inv.name = name;
      inv.toolId = name;
    }
    return inv;
  };

  for (const ev of events || []) {
    const p = ev.payload || ev || {};
    const type = ev.type || p.type;
    if (type === "TOOL_CALL_START") {
      const id = p.toolCallId;
      if (id) seenAgUi.add(id);
      const inv = ensureInvocation(id, p.toolCallName);
      inv.round = roundOfLlmId(p.parentMessageId) ?? inv.round;
    } else if (type === "TOOL_CALL_ARGS") {
      const inv = ensureInvocation(p.toolCallId, p.toolCallName);
      try {
        inv.args = JSON.parse(p.delta || "{}");
      } catch {
        inv.args = {};
      }
    } else if (type === "TOOL_CALL_RESULT") {
      const content = (p.content && typeof p.content === "object") ? p.content : {};
      const resultText = content.result ?? p.message ?? "";
      const isError = content.is_error ?? p.isError ?? false;
      const resultBytes = content.result_bytes || new Blob([resultText]).size;
      const dur = content.duration_ms || p.durationMs || 0;
      const inv = ensureInvocation(p.toolCallId, p.toolCallName);
      inv.running = false;
      inv.durMs = dur;
      inv.preview = isError ? `✗ error (${resultBytes} B)` : `✓ ${resultBytes} B`;
      inv.isError = !!isError;
      inv.resultText = resultText || null;
    } else if (type === "tool.started") {
      const id = p.tool_call_id;
      if (id && seenAgUi.has(id)) continue;
      const inv = ensureInvocation(id, p.tool_name);
      inv.args = p.arguments || inv.args || {};
      inv.round = roundOfLlmId(p.llm_round_id) ?? inv.round;
    } else if (type === "tool.completed") {
      const id = p.tool_call_id;
      if (id && seenAgUi.has(id)) continue;
      const inv = ensureInvocation(id, p.tool_name);
      const resultBytes = p.result_bytes || new Blob([p.result_text || ""]).size;
      inv.running = false;
      inv.durMs = p.duration_ms || 0;
      inv.preview = p.is_error ? `✗ error (${resultBytes} B)` : `✓ ${resultBytes} B`;
      inv.isError = !!p.is_error;
      inv.resultText = p.result_text || null;
    }
  }
  return ordered.filter((inv) => {
    if (!inv.name) return false;
    const args = inv.args || {};
    const internalFinalWrite = inv.name === "write_file" && inv.round == null && Object.keys(args).length === 0;
    return !internalFinalWrite;
  });
}

function Gutter({ side, leftW, rightW, setLeftW, setRightW, onReset }) {
  const onMouseDown = (e) => {
    e.preventDefault();
    const startX = e.clientX;
    const startLeft = leftW;
    const startRight = rightW;
    document.body.style.cursor = "col-resize";
    document.body.style.userSelect = "none";

    const onMove = (ev) => {
      const dx = ev.clientX - startX;
      const winW = window.innerWidth;
      if (side === "left") {
        const proposed = startLeft + dx;
        const maxByMid = winW - startRight - MIN_MID_W - 10;
        const next = Math.max(LEFT_BOUNDS.min, Math.min(LEFT_BOUNDS.max, Math.min(maxByMid, proposed)));
        setLeftW(next);
      } else {
        const proposed = startRight - dx;
        const maxByMid = winW - startLeft - MIN_MID_W - 10;
        const next = Math.max(RIGHT_BOUNDS.min, Math.min(RIGHT_BOUNDS.max, Math.min(maxByMid, proposed)));
        setRightW(next);
      }
    };
    const onUp = () => {
      window.removeEventListener("mousemove", onMove);
      window.removeEventListener("mouseup", onUp);
      document.body.style.cursor = "";
      document.body.style.userSelect = "";
    };
    window.addEventListener("mousemove", onMove);
    window.addEventListener("mouseup", onUp);
  };
  return (
    <div
      className="gutter"
      role="separator"
      aria-orientation="vertical"
      title="Drag to resize · double-click to reset"
      onMouseDown={onMouseDown}
      onDoubleClick={onReset}
    />
  );
}

function MarkdownDefaultPicker({ value, onChange }) {
  return (
    <div className="user-pref" title="Default assistant reply display">
      <span className="user-pref-label">Markdown</span>
      <button
        className={value === "preview" ? "active" : ""}
        onClick={() => onChange("preview")}
        title="Render assistant replies as Markdown by default"
      >
        Preview
      </button>
      <button
        className={value === "source" ? "active" : ""}
        onClick={() => onChange("source")}
        title="Show raw Markdown source by default"
      >
        Source
      </button>
    </div>
  );
}

function App() {
  const DEFAULT_AGENT_NAME = "英威腾-线索Agent-V1-本地开发版本";
  const resolveDefaultAgentId = (agentItems) => (
    agentItems.find((item) => item.name === DEFAULT_AGENT_NAME)?.id
    || agentItems[0]?.id
    || null
  );

  // Persisted column widths
  const [leftW, setLeftW] = useState(() => {
    const v = parseInt(localStorage.getItem("baizhi.playground.leftW") || "");
    return Number.isFinite(v) ? v : 288;
  });
  const [rightW, setRightW] = useState(() => {
    const v = parseInt(localStorage.getItem("baizhi.playground.rightW") || "");
    return Number.isFinite(v) ? v : 380;
  });
  useEffect(() => { localStorage.setItem("baizhi.playground.leftW", String(leftW)); }, [leftW]);
  useEffect(() => { localStorage.setItem("baizhi.playground.rightW", String(rightW)); }, [rightW]);
  const [markdownDefaultMode, setMarkdownDefaultMode] = useState(() => {
    const v = localStorage.getItem("baizhi.playground.markdownDefaultMode");
    return v === "source" ? "source" : "preview";
  });
  useEffect(() => {
    localStorage.setItem("baizhi.playground.markdownDefaultMode", markdownDefaultMode);
  }, [markdownDefaultMode]);
  // Double-click a gutter resets to default
  const resetWidths = () => { setLeftW(288); setRightW(380); };
  const [agents, setAgents] = useState([]);
  const [skills, setSkills] = useState([]);
  const [mcps, setMcps] = useState([]);
  // PR-S.17d · topbar 上 Users 入口 admin-gate
  const [isAdmin, setIsAdmin] = useState(false);
  useEffect(() => {
    window.api.authMe()
      .then((m) => setIsAdmin(!!m.is_admin))
      .catch(() => {});
  }, []);
  const [localTools, setLocalTools] = useState([]);
  const [activeId, setActiveId] = useState(null);
  // Chat sessions combine the selected agent's skills with the current user's
  // bound skills. The runtime de-duplicates collisions by template/name.
  const [bindings, setBindings] = useState({ skill_ids: [], mcp_ids: [] });
  // This chat's active skill / MCP set. Skills start from the de-duplicated
  // agent/user union; MCPs and local tools start from the agent defaults.
  // Switching agent / thread resets to the agent defaults.
  const [sessionSkills, setSessionSkills] = useState([]);  // string[] of skill_ids
  const [sessionMcps, setSessionMcps] = useState([]);      // string[] of mcp_ids
  const [sessionLocalTools, setSessionLocalTools] = useState([]); // string[] of model-facing tool names
  const sessionToolScopeRef = useRef(null);
  const [runContextByAgent, setRunContextByAgent] = useState({});
  const runContext = activeId ? (runContextByAgent[activeId] || {}) : {};
  const setRunContext = useCallback((nextContext) => {
    if (!activeId) return;
    setRunContextByAgent((prev) => ({
      ...prev,
      [activeId]: nextContext || {},
    }));
  }, [activeId]);
  const [threadId, setThreadId] = useState(rid("thread"));
  const [threadModelOverrides, setThreadModelOverrides] = useState({});
  const [messages, setMessages] = useState([]);
  const [runs, setRuns] = useState([]);
  const [historyLoading, setHistoryLoading] = useState(false);
  const [historyError, setHistoryError] = useState("");
  const volatileThreadIdsRef = useRef({});

  // Helpers: per-agent thread id lives in localStorage so refresh /
  // accidental close doesn't drop chat history. Clear button is the ONLY
  // way to discard (rotates the thread id, both locally and remotely).
  const threadKey = (agentId) => `baizhi.thread.${agentId}`;
  const getOrCreateThread = (agentId) => {
    if (!agentId) return rid("thread");
    const fresh = rid("thread");
    try {
      const stored = localStorage.getItem(threadKey(agentId));
      if (stored) {
        volatileThreadIdsRef.current[agentId] = stored;
        return stored;
      }
      localStorage.setItem(threadKey(agentId), fresh);
    } catch (err) {
      console.error("thread persistence unavailable", err);
      if (volatileThreadIdsRef.current[agentId]) return volatileThreadIdsRef.current[agentId];
    }
    volatileThreadIdsRef.current[agentId] = fresh;
    return fresh;
  };
  // Backend stores `{role, content, run_id?, output_assets?}`; UI message
  // shape needs id + time. toolInvocations / events are live-only and the
  // run's spans live on /v1/traces if the user wants to dig in. Old
  // assistant turns just display content. PR thread-asset-rehydrate:
  // assistant messages now carry `output_assets` so the download chip
  // survives a page refresh (mirror of the live `outputAssets` field set
  // in the run-complete handler below). Filter out result.md — its
  // contents are already in `content`.
  const rehydrateMessage = (m, i) => ({
    id: rid("msg"),
    role: m.role || "assistant",
    content: m.content || "",
    time: "",
    historical: true,
    runId: m.run_id || null,
    outputAssets: (m.output_assets || []).filter(
      (a) => a.filename !== "result.md"
    ),
  });
  const rehydrateMessages = async (items) => (items || []).map(rehydrateMessage);
  const runCardFromTraceDetail = (detail) => {
    const run = detail?.run || {};
    const summary = detail?.summary || {};
    const events = detail?.events || [];
    const usage = run.usage || summary.usage || {};
    const modelIO = [...(detail?.model_io || [])].sort((a, b) => {
      const roundDelta = (a.round_index || 0) - (b.round_index || 0);
      if (roundDelta) return roundDelta;
      return (a.started_at_ms || 0) - (b.started_at_ms || 0);
    });
    const modelIOFromEvents = events
      .filter((e) => ["model_io.started", "model_io.completed", "model_io.failed"].includes(e.type))
      .map((e) => e.payload || {});
    const mergedModelIO = modelIO.length
      ? modelIO
      : modelIOFromEvents.reduce((acc, call) => mergeModelIOCalls(acc, call), []);
    const modelIOByRound = new Map(mergedModelIO.map((call) => [call.round_index, call]));
    const steps = [];
    for (const e of events) {
      const p = e.payload || {};
      if (e.type === "llm.completed") {
        const call = modelIOByRound.get(p.round_index);
        steps.push(llmTimelineStep(p, { model: call?.model, usage: call?.usage }));
      } else if (e.type === "tool.completed") {
        steps.push({
          kind: "tool",
          label: p.tool_name || p["tool.name"] || "tool",
          durMs: p.duration_ms || p["tool.duration_ms"] || 1,
          batchId: p.tool_batch_id,
          batchSize: p.tool_batch_size,
        });
      } else if (e.type === "TOOL_CALL_RESULT") {
        steps.push({
          kind: "tool",
          label: p.toolCallName || p.toolCallId || "tool",
          durMs: p.content?.duration_ms || 1,
          batchId: p.content?.tool_batch_id,
          batchSize: p.content?.tool_batch_size,
        });
      }
    }
    const started = run.started_at || summary.started_at || run.queued_at || 0;
    const completed = run.completed_at || summary.completed_at || 0;
    const totalMs = summary.duration_ms || (
      started && completed ? Math.max(0, Math.round((completed - started) * 1000)) : 0
    );
    const status = run.status || summary.status || "completed";
    steps.push({ kind: "final", label: `RUN_FINISHED · ${status}`, durMs: 1 });
    return {
      runId: run.run_id || summary.run_id,
      threadId: run.thread_id || summary.thread_id,
      workspace: run.workspace || summary.workspace || null,
      running: false,
      historical: true,
      detailLoaded: true,
      detailLoading: false,
      steps,
      events,
      totalMs,
      modelIO: mergedModelIO,
      output: {
        runId: run.run_id || summary.run_id,
        threadId: run.thread_id || summary.thread_id,
        llmRounds: usage.llm_turns ?? usage.llm_rounds ?? mergedModelIO.length ?? 0,
        toolCalls: usage.tool_calls ?? steps.filter((s) => s.kind === "tool").length,
        totalDurationMs: totalMs,
        finishReason: status,
        inputTokens: usage.input_tokens ?? usage.prompt_tokens ?? usage.prompt_token_count ?? 0,
        outputTokens: usage.output_tokens ?? usage.completion_tokens ?? usage.candidates_token_count ?? 0,
        costUsd: usage.cost_usd ?? null,
      },
    };
  };
  const runCardFromEvents = (runId, events, base = {}) => {
    const steps = [];
    let workspaceFromEvents = base.workspace || null;
    for (const e of events || []) {
      const p = e.payload || {};
      if (e.type === "llm.completed") {
        steps.push(llmTimelineStep(p, { model: p.model || base.model || agent?.model }));
      } else if (e.type === "tool.completed") {
        steps.push({
          kind: "tool",
          label: p.tool_name || p["tool.name"] || "tool",
          durMs: p.duration_ms || p["tool.duration_ms"] || 1,
          batchId: p.tool_batch_id,
          batchSize: p.tool_batch_size,
        });
      } else if (e.type === "TOOL_CALL_RESULT") {
        steps.push({
          kind: "tool",
          label: p.toolCallName || p.toolCallId || "tool",
          durMs: p.content?.duration_ms || 1,
          batchId: p.content?.tool_batch_id,
          batchSize: p.content?.tool_batch_size,
        });
      } else if (e.type === "workspace.prepared") {
        workspaceFromEvents = { ...(workspaceFromEvents || {}), ...p };
      } else if (e.type === "workspace.files_synced") {
        workspaceFromEvents = {
          ...(workspaceFromEvents || {}),
          manifest_object_key: p.manifest_key,
          manifest_revision: p.revision || 0,
          workspace_sync_error: null,
        };
      } else if (e.type === "workspace.sync_failed") {
        workspaceFromEvents = {
          ...(workspaceFromEvents || {}),
          workspace_sync_error: p.message || "workspace sync failed",
        };
      } else if (e.type === "workspace.snapshot_saved") {
        workspaceFromEvents = {
          ...(workspaceFromEvents || {}),
          snapshot_object_key: p.object_key,
          snapshot_asset_id: p.asset_id || null,
          snapshot_filename: p.filename || "workspace-snapshot.tar.gz",
          snapshot_size: p.size || 0,
          snapshot_saved: !!p.saved,
          snapshot_error: p.error || null,
        };
      }
    }
    const done = [...(events || [])].reverse().find((e) => e.type === "run.completed" || e.type === "run.failed");
    const status = done?.type === "run.failed" ? "failed" : "completed";
    steps.push({ kind: "final", label: `RUN_FINISHED · ${status}`, durMs: 1 });
    return {
      ...base,
      runId,
      threadId: base.threadId || threadId,
      workspace: workspaceFromEvents,
      running: false,
      historical: true,
      detailLoaded: true,
      detailLoading: false,
      modelIOLoaded: false,
      modelIOLoading: false,
      steps,
      events,
      totalMs: base.totalMs || 0,
      output: base.output || {
        runId,
        threadId: base.threadId || threadId,
        finishReason: status,
      },
    };
  };
  const rehydrateRunsFromMessages = async (messages, persistedRuns, loadError = null) => {
    const byRunId = new Map();
    for (const msg of messages || []) {
      if (msg.role !== "assistant" || !msg.runId || byRunId.has(msg.runId)) continue;
      byRunId.set(msg.runId, msg);
    }
    const persisted = [...(persistedRuns || [])].reverse();
    if (!persisted.length && loadError) {
      return [...byRunId.keys()].map((runId) => ({
        runId,
        threadId,
        workspace: null,
        workspaceLoadError: loadError,
        running: false,
        status: "historical",
        historical: true,
        steps: [],
        events: [],
        totalMs: 0,
      }));
    }
    return persisted.map((run) => {
      const runId = run.run_id;
      const msg = byRunId.get(runId) || {};
      return {
        runId,
        threadId: run.thread_id || msg.threadId || threadId,
        workspace: run.workspace || null,
        running: ["queued", "running", "awaiting_input", "interrupted_max_rounds", "interrupted_context_budget"].includes(run.status),
        status: run.status || "historical",
        historical: true,
        detailLoaded: false,
        detailLoading: false,
        steps: [{
          kind: "final",
          label: `RUN_${String(run.status || "historical").toUpperCase()} · historical`,
          durMs: 1,
        }],
        events: [],
        totalMs: 0,
        output: {
          runId,
          threadId: msg.threadId || threadId,
          finishReason: "historical",
          preview: (msg.content || "").slice(0, 300),
          inputTokens: run.usage?.input_tokens ?? run.usage?.prompt_tokens ?? 0,
          outputTokens: run.usage?.output_tokens ?? run.usage?.completion_tokens ?? 0,
          costUsd: run.usage?.cost_usd ?? null,
        },
      };
    });
  };
  const [busy, setBusy] = useState(false);
  const [loaded, setLoaded] = useState(false);
  const [loadError, setLoadError] = useState(null);
  const runHandleRef = useRef(null);
  // HITL (docs/HIL-design.md): while a run is paused on `ask_human`, the driver
  // hands us a `respond` fn (via onAwaitingInput) that resumes the segment loop
  // when invoked. We stash it here so the inline form (rendered in the chat by
  // chat-panel) can submit the human's answer. Only one run is active at a time
  // (busy gate), so a single ref suffices.
  const humanRespondRef = useRef(null);
  const toolApprovalRespondRef = useRef(null);

  const isCancelledRunError = (err) => (
    err?.message === "cancelled" || err?.name === "AbortError"
  );

  const finishCancelledUi = (totalMs = null, stats = null) => {
    localStorage.removeItem("baizhi.active_run_id");
    setRuns((prev) => {
      if (!prev.length) return prev;
      const next = [...prev];
      const idx = next.length - 1;
      const current = next[idx];
      if (!current?.running) return prev;
      next[idx] = {
        ...current,
        running: false,
        totalMs: totalMs ?? current.totalMs ?? 0,
        steps: [...(current.steps || []), { kind: "final", label: "RUN_FINISHED · cancelled", durMs: 1 }],
      };
      return next;
    });
    setMessages((prev) => {
      if (!prev.length) return prev;
      const next = [...prev];
      const idx = next.length - 1;
      const current = next[idx];
      if (!current?.streaming) return prev;
      next[idx] = {
        ...current,
        streaming: false,
        reasoning: current.reasoning ? { ...current.reasoning, active: false } : current.reasoning,
        content: current.content || "（已取消）",
        status: "cancelled",
        errorText: null,
        ...(stats ? { stats } : {}),
      };
      return next;
    });
    setBusy(false);
  };

  // ---- Run reconnection ----
  // When the user refreshes the page, discover any runs still active on the
  // backend and re-attach via SSE. The SSE endpoint replays all events +
  // continues streaming; dispatchEvent dedups via seenEventIds so already-seen
  // events are not re-processed.
  const reattachToRun = useCallback(async (activeRun) => {
    setBusy(true);

    // Create run placeholder in the right panel
    const placeholder = {
      runId: activeRun.run_id, threadId: activeRun.thread_id,
      running: true, steps: [], events: [], totalMs: 0,
    };
    setRuns((r) => [...r, placeholder]);

    // Reconstruct chat messages from the run's persisted data
    const userMsg = {
      id: rid("msg"), role: "user",
      content: activeRun.message || "", time: "",
    };
    const asstMsg = {
      id: rid("msg"), role: "assistant", content: "", time: "",
      streaming: true, toolInvocations: [], stats: null,
      reasoning: { active: false, content: "" },
    };
    setMessages((m) => [...m, userMsg, asstMsg]);

    const t0 = performance.now();
    let llmRounds = 0;
    let toolCalls = 0;
    let inputTokens = 0;
    let outputTokens = 0;
    const openLlmRounds = new Map();
    const openToolCalls = new Map();
    const suppressedToolCalls = new Set();
    const seenEventIds = new Set();
    const receivedEvents = [];
    let sawAgUiTextContent = false;
    const agUiToolStarts = new Map();
    const agUiToolStarted = new Set();
    const agUiToolResults = new Set();
    const roundOf = (rid) => {
      const m = /llm_(\d+)/.exec(rid || "");
      return m ? parseInt(m[1], 10) : null;
    };

    const writeRun = (mutator) => {
      setRuns((prev) => {
        const next = [...prev];
        const idx = next.length - 1;
        next[idx] = mutator(next[idx]);
        return next;
      });
    };
    const writeAssistant = (mutator) => {
      setMessages((prev) => {
        const next = [...prev];
        const idx = next.length - 1;
        next[idx] = mutator(next[idx]);
        return next;
      });
    };

    // Open SSE connection — the endpoint replays all past events + continues
    // streaming live ones. Late joiners are fully supported.
    const url = `/v1/runs/${activeRun.run_id}/events?stream=1&${eventStreamAuthQuery()}`;
    let es = null;
    let reconnectTimer = null;
    let streamSettled = false;

    const closeCurrent = () => {
      if (!es) return;
      try { es.close(); } catch {}
      es = null;
    };
    const clearReconnect = () => {
      if (reconnectTimer !== null) {
        clearTimeout(reconnectTimer);
        reconnectTimer = null;
      }
    };

    const cleanup = () => {
      streamSettled = true;
      clearReconnect();
      closeCurrent();
      localStorage.removeItem("baizhi.active_run_id");
      setBusy(false);
      runHandleRef.current = null;
    };

    const dispatchEvent = (evt) => {
      const eid = evt && evt.event_id;
      if (eid !== undefined && eid !== null) {
        if (seenEventIds.has(eid)) return;
        seenEventIds.add(eid);
      }
      receivedEvents.push(evt);
      const t = evt.type;
      const p = evt.payload || {};

      if (["workspace.prepared", "workspace.files_synced", "workspace.sync_failed", "workspace.snapshot_saved"].includes(t)) {
        writeRun((r) => ({
          ...r,
          workspace: {
            ...(r.workspace || {}),
            ...(t === "workspace.prepared" ? p : t === "workspace.files_synced" ? {
              manifest_object_key: p.manifest_key,
              manifest_revision: p.revision || 0,
              workspace_sync_error: null,
            } : t === "workspace.sync_failed" ? {
              workspace_sync_error: p.message || "workspace sync failed",
            } : {
              snapshot_object_key: p.object_key,
              snapshot_asset_id: p.asset_id || null,
              snapshot_filename: p.filename || "workspace-snapshot.tar.gz",
              snapshot_size: p.size || 0,
              snapshot_saved: !!p.saved,
              snapshot_error: p.error || null,
            }),
          },
          events: [...r.events, { type: t, payload: p, timestamp: Date.now() }],
        }));
      } else if (t === "TEXT_MESSAGE_CONTENT") {
        sawAgUiTextContent = true;
        writeAssistant((m) => ({ ...m, content: (m.content || "") + (p.delta || "") }));
        writeRun((r) => ({
          ...r,
          events: [...r.events, { type: "TEXT_MESSAGE_CONTENT", delta: p.delta, timestamp: Date.now() }],
        }));
      } else if (t === "TOOL_CALL_START") {
        const toolCallId = p.toolCallId;
        if (toolCallId) {
          agUiToolStarted.add(toolCallId);
          agUiToolStarts.set(toolCallId, {
            tool_name: p.toolCallName,
            tool_call_id: toolCallId,
            arguments: {},
            llm_round_id: p.parentMessageId,
            dispatched: false,
          });
        }
      } else if (t === "TOOL_CALL_ARGS") {
        const started = agUiToolStarts.get(p.toolCallId);
        if (started && !started.dispatched) {
          let args = {};
          try { args = JSON.parse(p.delta || "{}"); } catch {}
          started.arguments = args;
          started.dispatched = true;
          const round = roundOf(started.llm_round_id);
          openToolCalls.set(started.tool_call_id, { name: started.tool_name, started: performance.now(), args, round });
          writeRun((r) => ({
            ...r,
            events: [...r.events,
              { type: "TOOL_CALL_START", toolCallId: started.tool_call_id, toolCallName: started.tool_name, timestamp: Date.now() },
              { type: "TOOL_CALL_ARGS", toolCallId: started.tool_call_id, toolCallName: started.tool_name, delta: JSON.stringify(args || {}), timestamp: Date.now() },
              { type: "TOOL_CALL_END", toolCallId: started.tool_call_id, timestamp: Date.now() },
            ],
          }));
          writeAssistant((m) => ({
            ...m,
            toolInvocations: [...(m.toolInvocations || []), {
              toolId: started.tool_name, name: started.tool_name,
              args: args || {}, durMs: 0,
              preview: "running…", running: true, round,
            }],
          }));
        }
      } else if (t === "TOOL_CALL_RESULT") {
        const toolCallId = p.toolCallId;
        if (toolCallId) agUiToolResults.add(toolCallId);
        const open = openToolCalls.get(toolCallId);
        const content = (p.content && typeof p.content === "object") ? p.content : {};
        const resultText = content.result ?? p.message ?? "";
        const isError = content.is_error ?? p.isError ?? false;
        const dur = content.duration_ms || p.durationMs || (open ? performance.now() - open.started : 0);
        openToolCalls.delete(toolCallId);
        toolCalls += 1;
        const resultBytes = content.result_bytes || new Blob([resultText]).size;
        const preview = isError
          ? `✗ error (${resultBytes} B)`
          : `✓ ${resultBytes} B`;
        writeRun((r) => ({
          ...r,
          steps: [...r.steps, {
            kind: "tool", label: p.toolCallName, durMs: dur,
            batchId: content.tool_batch_id,
            batchSize: content.tool_batch_size,
          }],
          events: [...r.events, {
            type: "TOOL_CALL_RESULT",
            messageId: p.messageId || `tool_${toolCallId}`,
            toolCallId,
            role: "tool",
            content: {
              result: resultText || "",
              is_error: !!isError,
              result_bytes: resultBytes,
              duration_ms: Math.round(dur),
              tool_batch_id: content.tool_batch_id,
              tool_batch_size: content.tool_batch_size,
              tool_timing_scope: content.tool_timing_scope,
            },
            timestamp: Date.now(),
          }],
        }));
        writeAssistant((m) => {
          const inv = [...(m.toolInvocations || [])];
          for (let i = inv.length - 1; i >= 0; i--) {
            if (inv[i].running && inv[i].name === p.toolCallName) {
              inv[i] = {
                ...inv[i], running: false, durMs: dur, preview,
                isError: !!isError, resultText: resultText || null,
              };
              break;
            }
          }
          return { ...m, toolInvocations: inv };
        });
      } else if (t === "llm.started") {
        openLlmRounds.set(p.round_id, performance.now());
      } else if (t === "llm.completed") {
        const started = openLlmRounds.get(p.round_id) ?? performance.now();
        openLlmRounds.delete(p.round_id);
        const dur = p.duration_ms || (performance.now() - started);
        llmRounds += 1;
        inputTokens += (p.input_tokens || 0);
        outputTokens += (p.output_tokens || 0);
        writeRun((r) => ({
          ...r,
          steps: [...r.steps, llmTimelineStep({ ...p, duration_ms: dur }, { model: agent?.model || "agent" })],
          output: {
            ...(r.output || {}),
            inputTokens,
            outputTokens,
            costUsd: (r.output?.costUsd ?? 0) + (numericUsageValue(p.cost_usd) ?? 0),
          },
        }));
      } else if (t === "model_io.started" || t === "model_io.completed" || t === "model_io.failed") {
        writeRun((r) => ({
          ...r,
          modelIO: mergeModelIOCalls(r.modelIO, p),
        }));
      } else if (t === "tool.started") {
        if (agUiToolStarted.has(p.tool_call_id)) return;
        if (p.tool_name === "write_file" && p.llm_round_id === "llm_final") {
          suppressedToolCalls.add(p.tool_call_id);
          return;
        }
        const round = roundOf(p.llm_round_id);
        openToolCalls.set(p.tool_call_id, { name: p.tool_name, started: performance.now(), args: p.arguments, round });
        writeRun((r) => ({
          ...r,
          events: [...r.events,
            { type: "TOOL_CALL_START", toolCallId: p.tool_call_id, toolCallName: p.tool_name, timestamp: Date.now() },
            { type: "TOOL_CALL_ARGS", toolCallId: p.tool_call_id, toolCallName: p.tool_name, delta: JSON.stringify(p.arguments || {}), timestamp: Date.now() },
            { type: "TOOL_CALL_END", toolCallId: p.tool_call_id, timestamp: Date.now() },
          ],
        }));
        writeAssistant((m) => ({
          ...m,
          toolInvocations: [...(m.toolInvocations || []), {
            toolId: p.tool_name, name: p.tool_name,
            args: p.arguments || {}, durMs: 0,
            preview: "running…", running: true, round,
          }],
        }));
      } else if (t === "tool.completed") {
        if (agUiToolResults.has(p.tool_call_id)) return;
        if (suppressedToolCalls.has(p.tool_call_id)) {
          suppressedToolCalls.delete(p.tool_call_id);
          return;
        }
        const open = openToolCalls.get(p.tool_call_id);
        const dur = p.duration_ms || (open ? performance.now() - open.started : 0);
        openToolCalls.delete(p.tool_call_id);
        toolCalls += 1;
        const preview = p.is_error
          ? `✗ error (${p.result_bytes || 0} B)`
          : `✓ ${p.result_bytes || 0} B`;
        writeRun((r) => ({
          ...r,
          steps: [...r.steps, {
            kind: "tool", label: p.tool_name, durMs: dur,
            batchId: p.tool_batch_id,
            batchSize: p.tool_batch_size,
          }],
          events: [...r.events, {
            type: "TOOL_CALL_RESULT",
            messageId: `tool_${p.tool_call_id}`,
            toolCallId: p.tool_call_id,
            role: "tool",
            content: {
              result: p.result_text || "",
              is_error: !!p.is_error,
              result_bytes: p.result_bytes || 0,
              duration_ms: Math.round(dur),
              tool_batch_id: p.tool_batch_id,
              tool_batch_size: p.tool_batch_size,
              tool_timing_scope: p.tool_timing_scope,
            },
            timestamp: Date.now(),
          }],
        }));
        writeAssistant((m) => {
          const inv = [...(m.toolInvocations || [])];
          for (let i = inv.length - 1; i >= 0; i--) {
            if (inv[i].running && inv[i].name === p.tool_name) {
              inv[i] = {
                ...inv[i], running: false, durMs: dur, preview,
                isError: !!p.is_error, resultText: p.result_text || null,
              };
              break;
            }
          }
          return { ...m, toolInvocations: inv };
        });
      } else if (t === "text.delta") {
        if (sawAgUiTextContent) return;
        writeAssistant((m) => ({ ...m, content: (m.content || "") + (p.delta || "") }));
        writeRun((r) => ({
          ...r,
          events: [...r.events, { type: "TEXT_MESSAGE_CONTENT", delta: p.delta, timestamp: Date.now() }],
        }));
      } else if (t === "REASONING_START" || t === "REASONING_MESSAGE_START") {
        writeAssistant((m) => ({
          ...m,
          reasoning: {
            active: true,
            id: p.messageId,
            content: m.reasoning?.content || "",
          },
        }));
        writeRun((r) => ({
          ...r,
          events: [...r.events, {
            type: t,
            messageId: p.messageId,
            role: p.role,
            timestamp: Date.now(),
          }],
        }));
      } else if (t === "REASONING_MESSAGE_CONTENT") {
        writeAssistant((m) => ({
          ...m,
          reasoning: {
            active: true,
            id: p.messageId,
            content: (m.reasoning?.content || "") + (p.delta || ""),
          },
        }));
        writeRun((r) => ({
          ...r,
          events: [...r.events, {
            type: "REASONING_MESSAGE_CONTENT",
            messageId: p.messageId,
            delta: p.delta || "",
            timestamp: Date.now(),
          }],
        }));
      } else if (t === "REASONING_MESSAGE_END" || t === "REASONING_END") {
        writeAssistant((m) => ({
          ...m,
          reasoning: {
            active: false,
            id: p.messageId,
            content: m.reasoning?.content || "",
          },
        }));
        writeRun((r) => ({
          ...r,
          events: [...r.events, {
            type: t,
            messageId: p.messageId,
            timestamp: Date.now(),
          }],
        }));
      } else if (t === "skill.activated") {
        writeAssistant((m) => ({
          ...m, activatedSkills: [...(m.activatedSkills || []), p.skill_name],
        }));
      } else if (t === "run.awaiting_input" && (p.response_route === "tool_approval" || p.interruption_type === "tool_approval")) {
        writeAssistant((m) => ({ ...m, toolApproval: p }));
      }
    };

    const handleData = (e) => {
      let evt;
      try { evt = JSON.parse(e.data); } catch { return; }
      dispatchEvent(evt);
    };

    const finalize = async (knownRun = null) => {
      if (streamSettled) return;
      try {
        const finalRun = knownRun || await window.api.getRun(activeRun.run_id);
        if (finalRun.status === "queued" || finalRun.status === "running") {
          scheduleReconnect();
          return;
        }
        streamSettled = true;
        clearReconnect();
        closeCurrent();
        const events = receivedEvents;
        const totalMs = performance.now() - t0;
        const finalEvent = events.find((e) => e.type === "text.delta");
        const fallbackText = !finalEvent
          ? events.filter((e) => e.type === "text.delta").map((e) => e.payload.delta).join("")
          : null;
        writeRun((r) => ({
          ...r, runId: finalRun.run_id, running: false, totalMs,
          workspace: finalRun.workspace || r.workspace || null,
          events: [...r.events, {
            type: "RUN_FINISHED", threadId: finalRun.thread_id,
            runId: finalRun.run_id, timestamp: Date.now(),
            output: { status: finalRun.status, error: finalRun.error },
          }],
          steps: [...r.steps, { kind: "final", label: `RUN_FINISHED · ${finalRun.status}`, durMs: 1 }],
          output: {
            runId: finalRun.run_id, threadId: finalRun.thread_id,
            llmRounds, toolCalls, totalDurationMs: Math.round(totalMs),
            finishReason: finalRun.status, inputTokens, outputTokens,
          },
        }));
        writeAssistant((m) => ({
          ...m, streaming: false,
          reasoning: m.reasoning ? { ...m.reasoning, active: false } : m.reasoning,
          content: fallbackText || m.content || (finalRun.error ? `（run ${finalRun.status}: ${finalRun.error}）` : ""),
          status: finalRun.status, errorText: finalRun.error || null,
          stats: { llmRounds, toolCalls, totalMs, tokensIn: inputTokens, tokensOut: outputTokens },
          outputAssets: (finalRun.output_assets || []).filter((a) => a.filename !== "result.md"),
        }));
      } catch (err) {
        writeRun((r) => ({ ...r, running: false, totalMs: performance.now() - t0 }));
        writeAssistant((m) => ({
          ...m, streaming: false,
          reasoning: m.reasoning ? { ...m.reasoning, active: false } : m.reasoning,
          content: `（重新连接失败：${err.message || err}）`,
        }));
      }
      cleanup();
    };

    const KNOWN_EVENTS = [
      "run.queued", "skill.activated", "skill.asset.prepared",
      "session.created", "session.reused", "workspace.prepared", "workspace.files_synced", "workspace.sync_failed", "workspace.snapshot_saved",
      "sandbox.prepared", "sandbox.released",
      "llm.started", "llm.completed",
      "model_io.started", "model_io.completed", "model_io.failed",
      "ui.card", "asset.created", "run.cancelled",
      "RUN_STARTED", "RUN_FINISHED", "RUN_ERROR",
      "TEXT_MESSAGE_START", "TEXT_MESSAGE_CONTENT", "TEXT_MESSAGE_END",
      "TOOL_CALL_START", "TOOL_CALL_ARGS", "TOOL_CALL_END", "TOOL_CALL_RESULT",
      "REASONING_START", "REASONING_MESSAGE_START", "REASONING_MESSAGE_CONTENT",
      "REASONING_MESSAGE_END", "REASONING_END",
      "agent.skill_tool_required", "agent.max_rounds_hit", "agent.cancelled",
      "run.awaiting_input", "run.resumed",
    ];
    const scheduleReconnect = () => {
      if (streamSettled || reconnectTimer !== null) return;
      reconnectTimer = setTimeout(() => {
        reconnectTimer = null;
        openEventSource();
      }, 1000);
    };

    const openEventSource = () => {
      if (streamSettled) return;
      closeCurrent();
      try { es = new EventSource(url); }
      catch (err) {
        console.warn("playground: SSE reconnect failed", err);
        scheduleReconnect();
        return;
      }
      const current = es;
      for (const t of KNOWN_EVENTS) current.addEventListener(t, handleData);
      current.addEventListener("done", () => { finalize(); });
      current.addEventListener("error", () => {
        if (streamSettled || current !== es) return;
        if (current.readyState === EventSource.CLOSED) {
          closeCurrent();
          finalize();
        }
      });
    };

    // Expose cancel handle so the Stop button works during reattachment
    runHandleRef.current = {
      promise: new Promise(() => {}),  // never resolves until SSE closes
      cancel: () => {
        const totalMs = performance.now() - t0;
        cleanup();
        window.api.cancelRun(activeRun.run_id, "user_abort").catch(() => {});
        finishCancelledUi(totalMs, { llmRounds, toolCalls, totalMs, tokensIn: inputTokens, tokensOut: outputTokens });
      },
    };
    openEventSource();
  }, []);

  // Initial load
  useEffect(() => {
    (async () => {
      try {
        const skillsList = await api.listSkills({ scope: "mine", runnable: true });
        window.SKILLS = skillsList;
        const [agentsList, mcpsList, localToolsList, modelsResp, tb] = await Promise.all([
          api.listAgents(),
          api.listMcpServers(),
          api.listLocalTools().catch(() => []),
          api.listModels().catch(() => null),  // 后端不可达时退回 bootstrap MODELS
          api.getUserBindings().catch(() => ({ skill_ids: [], mcp_ids: [] })),
        ]);
        // Mirror onto globals BEFORE we surface the data via state — child
        // components consult these at render time.
        window.MCPS = mcpsList;
        window.LOCAL_TOOLS = localToolsList;
        window.INITIAL_AGENTS = agentsList;
        // LLM 窗口的权威来源是后端;覆盖 bootstrap MODELS,Playground 显示真实 context_window。
        if (modelsResp && Array.isArray(modelsResp.items) && modelsResp.items.length) {
          window.MODELS = modelsResp.items;
        }
        if (modelsResp && Array.isArray(modelsResp.providers) && modelsResp.providers.length) {
          window.MODEL_PROVIDERS = modelsResp.providers;
        }
        setSkills(skillsList);
        setMcps(mcpsList);
        setLocalTools(localToolsList);
        setAgents(agentsList);
        setBindings(tb || { skill_ids: [], mcp_ids: [] });
        const defaultAgentId = resolveDefaultAgentId(agentsList);
        setActiveId(defaultAgentId);
        setLoaded(true);

        // Check for active runs to reattach after page refresh
        try {
          const activeRuns = await api.listActiveRuns();
          if (activeRuns && activeRuns.length > 0) {
            const match = activeRuns.find((r) => r.agent_id === defaultAgentId);
            if (match) {
              reattachToRun(match);
            }
          }
        } catch (err) {
          console.warn("playground: active run check failed", err);
        }
      } catch (err) {
        console.error("playground: initial load failed", err);
        setLoadError(err.message || String(err));
        setLoaded(true);
      }
    })();
  }, []);

  // Whenever the React state for skills/mcps/agents changes, keep the
  // window globals in sync so any component still reading them sees latest.
  useEffect(() => { window.SKILLS = skills; }, [skills]);
  useEffect(() => { window.MCPS = mcps; }, [mcps]);
  useEffect(() => { window.LOCAL_TOOLS = localTools; }, [localTools]);
  useEffect(() => { window.INITIAL_AGENTS = agents; }, [agents]);

  const agent = useMemo(() => agents.find((a) => a.id === activeId), [agents, activeId]);
  const threadModelOverride = threadModelOverrides[threadId] || {};
  const modelAgent = useMemo(() => {
    if (!agent) return agent;
    return {
      ...agent,
      modelProvider: threadModelOverride.provider ?? agent.modelProvider,
      model: threadModelOverride.model_name ?? agent.model,
      temperature: threadModelOverride.temperature ?? agent.temperature,
      top_p: threadModelOverride.top_p ?? agent.top_p,
      max_tokens: threadModelOverride.max_tokens ?? agent.max_tokens,
      reasoning_enabled: threadModelOverride.reasoning_enabled ?? agent.reasoning_enabled,
      reasoning_visible: threadModelOverride.reasoning_visible ?? agent.reasoning_visible,
    };
  }, [agent, threadModelOverride]);
  const effectiveSkillItems = useMemo(() => {
    if (!agent) return [];
    const byKey = new Map();
    const allSkillItems = [...(agent.skills || []), ...(skills || [])];
    const referencedTemplateIds = new Set(
      allSkillItems
        .map((item) => item?.forked_from?.template_id)
        .filter(Boolean)
    );
    const keyFor = (item) => {
      const templateId = item?.forked_from?.template_id;
      if (templateId) return `template:${templateId}`;
      const id = item?.id || item?.skill_id;
      // A personal skill can be the source template for an agent-owned fork
      // while having no forked_from field itself. Canonicalize that source
      // skill to the same template identity as its derived instance.
      if (id && referencedTemplateIds.has(id)) return `template:${id}`;
      return `name:${item?.name || id}`;
    };
    for (const item of agent.skills || []) {
      const id = item?.id || item?.skill_id;
      const key = keyFor(item);
      if (!id || byKey.has(key)) continue;
      byKey.set(key, { ...item, source: "agent" });
    }
    // User bindings are available to every Playground chat. A user-owned
    // fork of the same template intentionally replaces the agent-owned one.
    for (const item of skills || []) {
      const id = item?.id || item?.skill_id;
      if (!id) continue;
      byKey.set(keyFor(item), { ...item, source: "user" });
    }
    return Array.from(byKey.values()).filter((item) => item.id || item.skill_id);
  }, [agent, skills]);

  // Chat sessions use the de-duplicated union of agent and user-bound skills.
  // Only a real agent/thread switch restores defaults. Catalog or agent-config
  // refreshes preserve explicit deselections and only remove selections that
  // are no longer allowed.
  useEffect(() => {
    if (!agent) {
      sessionToolScopeRef.current = null;
      setSessionSkills([]);
      setSessionMcps([]);
      setSessionLocalTools([]);
      return;
    }
    const scopeKey = `${activeId}:${threadId}`;
    if (sessionToolScopeRef.current !== scopeKey) {
      sessionToolScopeRef.current = scopeKey;
      setSessionSkills(effectiveSkillItems.map((s) => s.id || s.skill_id));
      setSessionMcps([...new Set(agent.enabledMCPs || [])]);
      setSessionLocalTools([...new Set(agent.enabledLocalTools || agent.localToolNames || [])]);
      return;
    }

    const allowedSkills = new Set(effectiveSkillItems.map((s) => s.id || s.skill_id));
    const allowedMcps = new Set(agent.enabledMCPs || []);
    const allowedLocalTools = new Set(agent.enabledLocalTools || agent.localToolNames || []);
    setSessionSkills((selected) => selected.filter((id) => allowedSkills.has(id)));
    setSessionMcps((selected) => selected.filter((id) => allowedMcps.has(id)));
    setSessionLocalTools((selected) => selected.filter((id) => allowedLocalTools.has(id)));
  }, [activeId, threadId, effectiveSkillItems, agent && agent.enabledMCPs, agent && agent.enabledLocalTools, agent && agent.localToolNames]);

  // Session toggle handlers only narrow/restore tools already configured on
  // the selected agent. They never add user/global tools into this chat.
  const toggleSessionSkill = (id) => setSessionSkills((p) => {
    const allowed = new Set(effectiveSkillItems.map((s) => s.id || s.skill_id));
    if (!allowed.has(id)) return p;
    return p.includes(id) ? p.filter((x) => x !== id) : [...p, id];
  });
  const toggleSessionMcp = (id) => {
    setSessionMcps((p) => {
      if (!(agent?.enabledMCPs || []).includes(id)) return p;
      return p.includes(id) ? p.filter((x) => x !== id) : [...p, id];
    });
  };
  const toggleSessionLocalTool = (id) => setSessionLocalTools((p) => {
    const allowed = agent?.enabledLocalTools || agent?.localToolNames || [];
    if (!allowed.includes(id)) return p;
    return p.includes(id) ? p.filter((x) => x !== id) : [...p, id];
  });

  // ---- agent CRUD (Playground left panel) ----
  const patchAgentLocal = (id, patch) => {
    setAgents((prev) => prev.map((a) => {
      if (a.id !== id) return a;
      const next = { ...a, ...patch };
      api.persistAgentUi(next);
      return next;
    }));
  };

  const updateAgent = async (patch) => {
    if (!agent) return;
    // Optimistic local update (covers UI-only fields like model/temperature).
    patchAgentLocal(agent.id, patch);
    // If the patch touches a backend-persisted field, send PATCH.
    // MCP simplification(2026-05-26):`enabledMCPs` 现在也走 backend(`agent.enabled_mcp_servers`)。
    const touchesBackend = "name" in patch || "system" in patch || "promptVariables" in patch || "maxAgentRounds" in patch || "enabledSkills" in patch || "enabledMCPs" in patch || "enabledLocalTools" in patch;
    if (!touchesBackend) return;
    try {
      const merged = { ...agent, ...patch };
      // Ensure any newly-enabled skills are actually installed for the
      // tenant; install lazily on first use so the user doesn't have to.
      await ensureSkillsInstalled(merged.enabledSkills || []);
      const updated = await api.updateAgent(agent.id, {
        name: merged.name,
        system_prompt: merged.system || "",
        prompt_variables: merged.promptVariables || [],
        max_agent_rounds: merged.maxAgentRounds || 0,
        skill_ids: merged.enabledSkills || [],
        enabled_mcp_servers: merged.enabledMCPs || [],
        local_tool_names: merged.enabledLocalTools || merged.localToolNames || [],
      });
      // Re-apply: backend wins on persisted fields, UI overrides on the rest.
      setAgents((prev) => prev.map((a) => a.id === updated.id ? { ...a, ...updated } : a));
    } catch (err) {
      console.warn("updateAgent: backend save failed (kept local change)", err);
    }
  };

  const updateThreadModelConfig = (patch) => {
    if (!agent || !threadId) return;
    const wireKeys = {
      modelProvider: "provider",
      model: "model_name",
      temperature: "temperature",
      top_p: "top_p",
      max_tokens: "max_tokens",
      reasoning_enabled: "reasoning_enabled",
      reasoning_visible: "reasoning_visible",
    };
    const agentDefaults = {
      provider: agent.modelProvider,
      model_name: agent.model,
      temperature: agent.temperature,
      top_p: agent.top_p,
      max_tokens: agent.max_tokens,
      reasoning_enabled: agent.reasoning_enabled,
      reasoning_visible: agent.reasoning_visible,
    };
    setThreadModelOverrides((previous) => {
      const nextOverride = { ...(previous[threadId] || {}) };
      for (const [uiKey, value] of Object.entries(patch)) {
        const wireKey = wireKeys[uiKey];
        if (!wireKey) continue;
        if (Object.is(value, agentDefaults[wireKey])) delete nextOverride[wireKey];
        else nextOverride[wireKey] = value;
      }
      const next = { ...previous };
      if (Object.keys(nextOverride).length) next[threadId] = nextOverride;
      else delete next[threadId];
      return next;
    });
  };

  const resetThreadModelConfig = () => {
    setThreadModelOverrides((previous) => {
      const next = { ...previous };
      delete next[threadId];
      return next;
    });
  };

  // PR pg-new-agent-to-manage: route "+ New agent" to the Manage page
  // instead of inline-creating an "Agent N" placeholder. Old behavior
  // created an agent with a generic name + empty system prompt; user had
  // to immediately rename + re-fill it from Manage anyway. Going straight
  // to Manage with ?new=agent removes the half-step.
  const addAgent = () => {
    window.location.href = "/manage?new=agent";
  };

  const selectAgent = (id) => {
    setThreadModelOverrides({});
    setActiveId(id);
    // Don't wipe messages here — the useEffect on activeId reloads the
    // chosen agent's persisted thread + history. Just reset the in-memory
    // run cards (those are live-only) so the right panel starts clean.
    setRuns([]);
  };

  const loadRunDetail = async (runId) => {
    if (!runId) return;
    setRuns((prev) => prev.map((r) => (
      r.runId === runId ? { ...r, detailLoading: true, detailError: null } : r
    )));
    try {
      const events = await window.api.getTraceEvents(runId, { include_model_events: false });
      const existing = runs.find((r) => r.runId === runId) || {};
      const card = runCardFromEvents(runId, events, existing);
      setRuns((prev) => prev.map((r) => (
        r.runId === runId
          ? { ...r, ...card, detailLoaded: true, detailLoading: false, detailError: null }
          : r
      )));
    } catch (err) {
      const msg = err.message || String(err);
      setRuns((prev) => prev.map((r) => (
        r.runId === runId ? { ...r, detailLoading: false, detailError: msg } : r
      )));
    }
  };

  const loadRunModelIO = async (runId) => {
    if (!runId) return;
    setRuns((prev) => prev.map((r) => (
      r.runId === runId ? { ...r, modelIOLoading: true, modelIOError: null } : r
    )));
    try {
      const modelIO = await window.api.getTraceModelIO(runId);
      setRuns((prev) => prev.map((r) => (
        r.runId === runId
          ? { ...r, modelIO, modelIOLoaded: true, modelIOLoading: false, modelIOError: null }
          : r
      )));
    } catch (err) {
      const msg = err.message || String(err);
      setRuns((prev) => prev.map((r) => (
        r.runId === runId ? { ...r, modelIOLoading: false, modelIOError: msg } : r
      )));
    }
  };

  const activateThread = (nextThreadId) => {
    if (!activeId) return false;
    volatileThreadIdsRef.current[activeId] = nextThreadId;
    try {
      localStorage.setItem(threadKey(activeId), nextThreadId);
    } catch (err) {
      console.error("failed to persist thread id", err);
      window.alert("浏览器存储不可用，Thread ID 仅在当前页面内有效。");
    }
    setThreadId(nextThreadId);
    setRunContextByAgent((prev) => {
      const next = { ...prev };
      delete next[activeId];
      return next;
    });
    setMessages([]);
    setRuns([]);
    return true;
  };

  const clearThread = () => {
    // Rotate the persisted id and let the next message start a fresh thread.
    activateThread(rid("thread"));
  };

  const changeThreadId = (rawThreadId) => {
    if (!activeId) return threadId;
    if (busy) {
      window.alert("当前任务运行中，结束或停止后才能切换 Thread ID。");
      return threadId;
    }

    const requested = String(rawThreadId || "").trim();
    const nextThreadId = requested || rid("thread");
    if (nextThreadId === threadId) return threadId;
    return activateThread(nextThreadId) ? nextThreadId : threadId;
  };

  // Rehydrate whenever the selected agent or thread changes. A user-entered
  // thread id is therefore a real context switch, not a cosmetic label.
  useEffect(() => {
    if (!activeId || !threadId) return;
    const persistedThreadId = getOrCreateThread(activeId);
    if (persistedThreadId !== threadId) {
      setThreadId(persistedThreadId);
      return;
    }
    let cancelled = false;
    setHistoryLoading(true);
    setHistoryError("");
    (async () => {
      try {
        const [messagesResult, runsResult] = await Promise.allSettled([
          window.api.listThreadMessages(threadId),
          window.api.listThreadRuns(threadId, 100),
        ]);
        if (cancelled) return;
        const items = messagesResult.status === "fulfilled" ? messagesResult.value : [];
        if (messagesResult.status === "rejected") {
          console.warn("listThreadMessages failed", messagesResult.reason);
          setHistoryError("历史消息加载失败，工作区文件仍可查看。");
        }
        const hydrated = await rehydrateMessages(items);
        if (cancelled) return;
        setMessages(hydrated);
        const persistedRuns = runsResult.status === "fulfilled" ? runsResult.value : [];
        let workspaceLoadError = null;
        if (runsResult.status === "rejected") {
          workspaceLoadError = runsResult.reason?.message || String(runsResult.reason);
          console.warn("listThreadRuns failed", runsResult.reason);
          setHistoryError((current) => current || "历史工作区加载失败，请重试。");
        }
        const hydratedRuns = await rehydrateRunsFromMessages(
          hydrated, persistedRuns, workspaceLoadError
        );
        if (cancelled) return;
        setRuns((prev) => {
          const active = (prev || []).filter((r) => r.running);
          const activeIds = new Set(active.map((r) => r.runId));
          return [
            ...hydratedRuns.filter((r) => !activeIds.has(r.runId)),
            ...active,
          ];
        });
      } catch (err) {
        if (!cancelled) {
          console.warn("listThreadMessages failed", err);
          setMessages([]);
          setRuns((prev) => (prev || []).filter((r) => r.running));
        }
      } finally {
        if (!cancelled) setHistoryLoading(false);
      }
    })();
    return () => { cancelled = true; };
  }, [activeId, threadId]);

  // Install any selected skill IDs not yet installed for the tenant. Skips silently
  // for skills with no `latest_version` (drafts that have never been
  // published) and surfaces a clear error rather than the cryptic backend
  // "skill version not found".
  const ensureSkillsInstalled = async (skillIds) => {
    const missing = [];
    for (const skillId of skillIds) {
      const sk = skills.find((s) => s.skill_id === skillId || s.id === skillId || s.name === skillId);
      if (!sk) continue;
      if (sk.installed_version) continue;
      if (!sk.latest_version) {
        missing.push(sk.name || skillId);
        continue;
      }
      try {
        await api.installSkill(sk.skill_id, sk.latest_version);
      } catch (err) {
        console.warn("install skill failed", skillId, err);
      }
    }
    if (missing.length) {
      throw new Error(`Skills missing published versions: ${missing.join(", ")}. Publish them in Manager first.`);
    }
    // Refresh the skills list so the UI shows updated installed_version.
    const fresh = await api.listSkills({ scope: "mine" });
    window.SKILLS = fresh;
    setSkills(fresh);
  };

  const send = async (text, attachments = []) => {
    if (busy || !agent) return;
    setBusy(true);

    // PR chat-uploads-workspace: `attachments` is a list of asset_ids from
    // prior uploadAttachment calls (collected by Composer). Threaded straight
    // through to startAgentRun → backend resolves + materializes into
    // workspace/input/<filename>.
    const userMsg = { id: rid("msg"), role: "user", content: text, time: nowHHMMSS(), attachments };
    const asstMsg = {
      id: rid("msg"), role: "assistant", content: "", time: nowHHMMSS(),
      streaming: true, toolInvocations: [], stats: null,
      reasoning: { active: false, content: "" },
    };
    setMessages((m) => [...m, userMsg, asstMsg]);

    const t0 = performance.now();
    const placeholder = {
      runId: rid("run"), threadId,
      pendingBackendRunId: true,
      running: true, steps: [], events: [], totalMs: 0,
    };
    setRuns((r) => [...r, placeholder]);

    // Per-run mutable state we mutate via callbacks. We replace the LAST
    // run entry in setRuns each callback so React sees a new array.
    let llmRounds = 0;
    let toolCalls = 0;
    let inputTokens = 0;
    let outputTokens = 0;
    const openLlmRounds = new Map(); // roundId → start perf time
    const openToolCalls = new Map(); // toolCallId → {name, started}
    // Tool-call ids we suppress from the chat (the runtime-internal write of
    // output/result.md — plumbing, not an agent action). Tracked by id so the
    // matching tool.completed is suppressed too; the agent's OWN write_file
    // calls (WorkspaceToolset) are NOT suppressed and render normally.
    const suppressedToolCalls = new Set();
    // Tool invocations carry a `round` number so the chat card can show
    // `round N` when the LLM goes through several tool-loop iterations.
    // Parsed from each backend event's `llm_round_id` (e.g. "llm_2" → 2).
    const roundOf = (rid) => {
      const m = /llm_(\d+)/.exec(rid || "");
      return m ? parseInt(m[1], 10) : null;
    };

    const writeRun = (mutator) => {
      setRuns((prev) => {
        const next = [...prev];
        const idx = next.length - 1;
        next[idx] = mutator(next[idx]);
        return next;
      });
    };
    const writeAssistant = (mutator) => {
      setMessages((prev) => {
        const next = [...prev];
        const idx = next.length - 1;
        next[idx] = mutator(next[idx]);
        return next;
      });
    };

    try {
      const trimmedRunContext = trimRunContext(runContext);
      const effectiveLocalToolNames = [...new Set([
        ...sessionLocalTools,
      ])];
      // Send the exact session-selected subset. Non-strict resolution lets the
      // backend resolve the selected user and agent skill union consistently.
      runHandleRef.current = startAgentRun({
        thread_id: threadId,
        message: text,
        agent_id: agent.id,
        skill_ids: sessionSkills,
        mcp_ids: sessionMcps,
        local_tool_names: effectiveLocalToolNames,
        restrict_to_agent_tools: false,
        context: trimmedRunContext,
        model_config: threadModelOverride,
        attachments,
        callbacks: {
          onQueued: ({ run }) => {
            localStorage.setItem("baizhi.active_run_id", run.run_id);
            writeRun((r) => ({
              ...r,
              runId: run.run_id,
              pendingBackendRunId: false,
              events: [...r.events, { type: "RUN_STARTED", threadId, runId: run.run_id, timestamp: Date.now(), metadata: { source: "playground", agentId: agent.id } }],
            }));
          },
          onLlmStarted: ({ round_id }) => {
            openLlmRounds.set(round_id, performance.now());
          },
          onLlmCompleted: ({ round_id, round_index, duration_ms, input_tokens, output_tokens, cost_usd, model }) => {
            const started = openLlmRounds.get(round_id) ?? performance.now();
            openLlmRounds.delete(round_id);
            const dur = duration_ms || (performance.now() - started);
            llmRounds += 1;
            inputTokens += (input_tokens || 0);
            outputTokens += (output_tokens || 0);
            writeRun((r) => ({
              ...r,
              steps: [...r.steps, llmTimelineStep(
                { round_index, duration_ms: dur, input_tokens, output_tokens, cost_usd, model },
                { model: model || agent.model },
              )],
              output: {
                ...(r.output || {}),
                inputTokens,
                outputTokens,
                costUsd: (r.output?.costUsd ?? 0) + (numericUsageValue(cost_usd) ?? 0),
              },
            }));
          },
          onModelIOStarted: (call) => {
            writeRun((r) => ({
              ...r,
              modelIO: mergeModelIOCalls(r.modelIO, call),
            }));
          },
          onModelIOCompleted: (call) => {
            writeRun((r) => ({
              ...r,
              modelIO: mergeModelIOCalls(r.modelIO, call),
            }));
          },
          onModelIOFailed: (call) => {
            writeRun((r) => ({
              ...r,
              modelIO: mergeModelIOCalls(r.modelIO, call),
            }));
          },
          onThinkingStarted: ({ thinking_id, llm_round_id }) => {
            writeAssistant((m) => ({
              ...m,
              reasoning: {
                active: true,
                id: thinking_id,
                content: m.reasoning?.content || "",
              },
            }));
            writeRun((r) => ({
              ...r,
              events: [...r.events,
                {
                  type: "REASONING_START",
                  messageId: thinking_id,
                  llmRoundId: llm_round_id,
                  timestamp: Date.now(),
                },
                {
                  type: "REASONING_MESSAGE_START",
                  messageId: thinking_id,
                  role: "assistant",
                  llmRoundId: llm_round_id,
                  timestamp: Date.now(),
                },
              ],
            }));
          },
          onThinkingDelta: ({ thinking_id, delta }) => {
            writeAssistant((m) => ({
              ...m,
              reasoning: {
                active: true,
                id: thinking_id,
                content: (m.reasoning?.content || "") + (delta || ""),
              },
            }));
            writeRun((r) => ({
              ...r,
              events: [...r.events, {
                type: "REASONING_MESSAGE_CONTENT",
                messageId: thinking_id,
                delta: delta || "",
                timestamp: Date.now(),
              }],
            }));
          },
          onThinkingCompleted: ({ thinking_id, text }) => {
            writeAssistant((m) => ({
              ...m,
              reasoning: {
                active: false,
                id: thinking_id,
                content: text || m.reasoning?.content || "",
              },
            }));
            writeRun((r) => ({
              ...r,
              events: [...r.events,
                {
                  type: "REASONING_MESSAGE_END",
                  messageId: thinking_id,
                  text: text || "",
                  timestamp: Date.now(),
                },
                {
                  type: "REASONING_END",
                  messageId: thinking_id,
                  timestamp: Date.now(),
                },
              ],
            }));
          },
          onToolStarted: ({ tool_name, tool_call_id, arguments: args, llm_round_id }) => {
            // Suppress ONLY the runtime-internal `write_file` that saves the
            // final reply to output/result.md — it's plumbing, not an agent
            // action (adk_backend emits it via execute_tool at "llm_final" with
            // no arguments). The agent's own write_file calls (WorkspaceToolset,
            // e.g. authoring a build script) carry a real llm_round_id + path
            // and DO render.
            if (tool_name === "write_file" && llm_round_id === "llm_final") {
              suppressedToolCalls.add(tool_call_id);
              return;
            }
            const round = roundOf(llm_round_id);
            openToolCalls.set(tool_call_id, { name: tool_name, started: performance.now(), args, round });
            writeRun((r) => ({
              ...r,
              events: [...r.events,
                { type: "TOOL_CALL_START", toolCallId: tool_call_id, toolCallName: tool_name, timestamp: Date.now() },
                { type: "TOOL_CALL_ARGS", toolCallId: tool_call_id, toolCallName: tool_name, delta: JSON.stringify(args || {}), timestamp: Date.now() },
                { type: "TOOL_CALL_END", toolCallId: tool_call_id, timestamp: Date.now() },
              ],
            }));
            writeAssistant((m) => ({
              ...m,
              toolInvocations: [...(m.toolInvocations || []), {
                toolId: tool_name,
                name: tool_name,
                args: args || {},
                durMs: 0,
                preview: "running…",
                running: true,
                round,
              }],
            }));
          },
          onToolCompleted: ({ tool_name, tool_call_id, duration_ms, is_error, result_bytes, result_text }) => {
            if (suppressedToolCalls.has(tool_call_id)) {  // mirror onToolStarted filter (by id)
              suppressedToolCalls.delete(tool_call_id);
              return;
            }
            const open = openToolCalls.get(tool_call_id);
            const dur = duration_ms || (open ? performance.now() - open.started : 0);
            openToolCalls.delete(tool_call_id);
            toolCalls += 1;
            const preview = is_error
              ? `✗ error (${result_bytes || 0} B)`
              : `✓ ${result_bytes || 0} B`;
            writeRun((r) => ({
              ...r,
              steps: [...r.steps, { kind: "tool", label: tool_name, durMs: dur }],
              events: [...r.events, {
                type: "TOOL_CALL_RESULT",
                messageId: `tool_${tool_call_id}`,
                toolCallId: tool_call_id,
                role: "tool",
                content: {
                  result: result_text || "",
                  is_error: !!is_error,
                  result_bytes: result_bytes || 0,
                  duration_ms: Math.round(dur),
                },
                timestamp: Date.now(),
              }],
            }));
            writeAssistant((m) => {
              const inv = [...(m.toolInvocations || [])];
              for (let i = inv.length - 1; i >= 0; i--) {
                if (inv[i].running && inv[i].name === tool_name) {
                  // pr-tool-error-visibility-ui: keep the full result_text on the
                  // invocation so chat-panel can render the actual error message
                  // (truncated server-side to 1024 chars). Without this the user
                  // saw "× error (66 B)" with no way to see WHAT the error said.
                  inv[i] = {
                    ...inv[i], running: false, durMs: dur, preview,
                    isError: !!is_error,
                    resultText: result_text || null,
                  };
                  break;
                }
              }
              return { ...m, toolInvocations: inv };
            });
          },
          onTextDelta: ({ delta }) => {
            writeAssistant((m) => ({ ...m, content: (m.content || "") + (delta || "") }));
            writeRun((r) => ({
              ...r,
              events: [...r.events, { type: "TEXT_MESSAGE_CONTENT", delta, timestamp: Date.now() }],
            }));
          },
          onSkillActivated: ({ skill_name }) => {
            // Surface in the chat footer so the user can see which skills
            // the run actually loaded (especially useful when an agent has
            // multiple skills enabled but only some matched the request).
            writeAssistant((m) => ({
              ...m,
              activatedSkills: [...(m.activatedSkills || []), skill_name],
            }));
          },
          onAgentEvent: (evt) => {
            if (["workspace.prepared", "workspace.files_synced", "workspace.sync_failed", "workspace.snapshot_saved"].includes(evt.type)) {
              const p = evt.payload || {};
              writeRun((r) => ({
                ...r,
                workspace: {
                  ...(r.workspace || {}),
                  ...(evt.type === "workspace.prepared" ? p : evt.type === "workspace.files_synced" ? {
                    manifest_object_key: p.manifest_key,
                    manifest_revision: p.revision || 0,
                    workspace_sync_error: null,
                  } : evt.type === "workspace.sync_failed" ? {
                    workspace_sync_error: p.message || "workspace sync failed",
                  } : {
                    snapshot_object_key: p.object_key,
                    snapshot_asset_id: p.asset_id || null,
                    snapshot_filename: p.filename || "workspace-snapshot.tar.gz",
                    snapshot_size: p.size || 0,
                    snapshot_saved: !!p.saved,
                    snapshot_error: p.error || null,
                  }),
                },
                events: [...r.events, { type: evt.type, payload: p, timestamp: Date.now() }],
              }));
            }
            // Inline alerts for the events that need user attention even
            // before the run completes — currently just the round-cap hit.
            if (evt.type === "agent.max_rounds_hit") {
              writeAssistant((m) => ({
                ...m,
                alerts: [...(m.alerts || []), {
                  level: "warn",
                  text: `LLM agent loop hit the ${evt.payload?.max_rounds || "?"}-round cap without a clean stop. Check SKILL.md instructions or raise MAX_AGENT_ROUNDS.`,
                }],
              }));
            }
          },
          onCompleted: ({ run, events }) => {
            localStorage.removeItem("baizhi.active_run_id");
            const totalMs = performance.now() - t0;
            // Replace assistant content with the final asset text if we
            // never received text deltas (deterministic backend skips them).
            const finalEvent = events.find((e) => e.type === "text.delta");
            const fallbackText = !finalEvent
              ? events.filter((e) => e.type === "text.delta").map((e) => e.payload.delta).join("")
              : null;
            writeRun((r) => ({
              ...r,
              runId: run.run_id,
              running: false,
              totalMs,
              workspace: run.workspace || r.workspace || null,
              events: [...r.events, { type: "RUN_FINISHED", threadId, runId: run.run_id, timestamp: Date.now(), output: { status: run.status, error: run.error } }],
              steps: [...r.steps, { kind: "final", label: `RUN_FINISHED · ${run.status}`, durMs: 1 }],
              output: {
                runId: run.run_id,
                threadId,
                llmRounds, toolCalls,
                totalDurationMs: Math.round(totalMs),
                finishReason: run.status,
              inputTokens, outputTokens,
              costUsd: run.usage?.cost_usd ?? r.output?.costUsd ?? null,
            },
            }));
            writeAssistant((m) => ({
              ...m,
              streaming: false,
              reasoning: m.reasoning ? { ...m.reasoning, active: false } : m.reasoning,
              content: fallbackText || m.content || (run.error ? `（run ${run.status}: ${run.error}）` : ""),
              status: run.status,            // "completed" | "failed" | "cancelled" | "timeout"
              errorText: run.error || null,
              stats: { llmRounds, toolCalls, totalMs, tokensIn: inputTokens, tokensOut: outputTokens },
              // PR C: surface output assets (PPT / DOCX / etc.) so the chat
              // can render download links. result.md is filtered out — its
              // contents already live in `content` above.
              outputAssets: (run.output_assets || []).filter((a) => a.filename !== "result.md"),
            }));
          },
          onCancelled: () => {
            const totalMs = performance.now() - t0;
            finishCancelledUi(totalMs, { llmRounds, toolCalls, totalMs, tokensIn: inputTokens, tokensOut: outputTokens });
          },
          onError: (err) => {
            if (isCancelledRunError(err)) {
              const totalMs = performance.now() - t0;
              finishCancelledUi(totalMs, { llmRounds, toolCalls, totalMs, tokensIn: inputTokens, tokensOut: outputTokens });
              return;
            }
            localStorage.removeItem("baizhi.active_run_id");
            writeRun((r) => ({ ...r, running: false, totalMs: performance.now() - t0 }));
            writeAssistant((m) => ({
              ...m,
              streaming: false,
              reasoning: m.reasoning ? { ...m.reasoning, active: false } : m.reasoning,
              content: `（运行失败：${err.message || err}）`,
              stats: { llmRounds, toolCalls, totalMs: performance.now() - t0, tokensIn: 0, tokensOut: 0 },
            }));
          },
          // HITL pause: the agent called `ask_human` → the run saved its
          // session and exited (docs/HIL-design.md). Render the inline form
          // from `run.human_request` and stash the resume fn; the run stays
          // paused (no thread held) until the human submits via the form.
          onAwaitingInput: ({ run, respond }) => {
            humanRespondRef.current = respond;
            const hr = run.human_request || {};
            const budgetInterrupted = run.status === "interrupted_context_budget";
            const maxRoundsInterrupted = run.status === "interrupted_max_rounds";
            writeRun((r) => ({ ...r, running: false, awaitingInput: true }));
            writeAssistant((m) => ({
              ...m,
              streaming: false,
              awaitingInput: {
                prompt: budgetInterrupted
                  ? "上下文预算已用尽，进度已保存。继续后将从压缩检查点接着完成。"
                  : maxRoundsInterrupted
                  ? "已达到本轮执行上限，进度已保存。继续后将从检查点接着完成。"
                  : (hr.prompt || "需要你补充信息后才能继续。"),
                fields: maxRoundsInterrupted
                  ? []
                  : (hr.fields && hr.fields.length)
                  ? hr.fields
                  : [{ name: "reply", label: "回复", type: "text" }],
              },
            }));
          },
          onToolApprovalRequired: ({ payload, respond }) => {
            toolApprovalRespondRef.current = respond;
            writeAssistant((m) => ({
              ...m,
              toolApproval: payload,
            }));
          },
        },
      });
      await runHandleRef.current.promise.catch(() => {});
    } catch (err) {
      if (isCancelledRunError(err)) {
        finishCancelledUi(performance.now() - t0);
        return;
      }
      writeAssistant((m) => ({
        ...m,
        streaming: false,
        reasoning: m.reasoning ? { ...m.reasoning, active: false } : m.reasoning,
        content: `（运行失败：${err.message || err}）`,
      }));
      writeRun((r) => ({ ...r, running: false, totalMs: performance.now() - t0 }));
    } finally {
      runHandleRef.current = null;
      setBusy(false);
    }
  };

  const stop = () => {
    localStorage.removeItem("baizhi.active_run_id");
    const handle = runHandleRef.current;
    handle?.cancel();
    finishCancelledUi();
    runHandleRef.current = null;
  };

  // HITL: the human filled the inline form → resume the paused run. Calling
  // the stashed `respond` resolves the driver's segment-loop wait; the driver
  // then POSTs /respond and re-attaches, so the SAME assistant message keeps
  // streaming the resumed work (onTextDelta / onToolStarted / onCompleted).
  // We clear the form + flip back to streaming so the UI reads as "working".
  const submitHumanInput = (values) => {
    const respond = humanRespondRef.current;
    if (!respond) return;
    humanRespondRef.current = null;
    setMessages((prev) => {
      const next = [...prev];
      const idx = next.length - 1;
      if (idx >= 0) next[idx] = { ...next[idx], awaitingInput: null, streaming: true };
      return next;
    });
    setRuns((prev) => {
      const next = [...prev];
      const idx = next.length - 1;
      if (idx >= 0) next[idx] = { ...next[idx], running: true, awaitingInput: false };
      return next;
    });
    respond(values);
  };

  const submitToolApproval = async (decision, detail) => {
    const respond = toolApprovalRespondRef.current;
    if (!respond) return;
    toolApprovalRespondRef.current = null;
    setMessages((prev) => {
      const next = [...prev];
      const idx = next.length - 1;
      if (idx >= 0 && next[idx].toolApproval) {
        const anchorAt = (next[idx].toolInvocations || []).length;
        next[idx] = {
          ...next[idx],
          toolApproval: {
            ...next[idx].toolApproval,
            submitted: true,
            submittedDecision: decision,
            submittedValues: detail?.modified_args || null,
            submittedReason: detail?.rejection_reason || null,
          },
          toolApprovalAt: anchorAt,
        };
      }
      return next;
    });
    try {
      await respond(decision, detail || {});
    } catch (err) {
      toolApprovalRespondRef.current = respond;
      setMessages((prev) => {
        const next = [...prev];
        const idx = next.length - 1;
        if (idx >= 0) {
          const restoredApproval = next[idx].toolApproval
            ? { ...next[idx].toolApproval }
            : next[idx].toolApproval;
          if (restoredApproval) {
            delete restoredApproval.submitted;
            delete restoredApproval.submittedDecision;
            delete restoredApproval.submittedValues;
            delete restoredApproval.submittedReason;
          }
          next[idx] = {
            ...next[idx],
            toolApproval: restoredApproval,
            toolApprovalAt: undefined,
            alerts: [
              ...(next[idx].alerts || []),
              { level: "error", text: `工具审批失败：${err.message || err}` },
            ],
          };
        }
        return next;
      });
    }
  };

  if (!loaded) {
    return (
      <div style={{ padding: 24, fontFamily: "ui-sans-serif, system-ui, sans-serif", color: "#6e6e80" }}>
        Loading…
      </div>
    );
  }
  if (loadError) {
    return (
      <div style={{ padding: 24, fontFamily: "ui-sans-serif, system-ui, sans-serif" }}>
        <h3 style={{ color: "#b91c1c" }}>Failed to load playground</h3>
        <pre style={{ background: "#f7f7f8", padding: 12, borderRadius: 6, overflow: "auto" }}>{loadError}</pre>
        <p style={{ color: "#6e6e80" }}>
          Check the runtime is up at <code>/healthz</code> and the tenant header (currently
          <code> {api.userId}</code>) is acceptable.
        </p>
      </div>
    );
  }
  if (!agent) {
    return (
      <div style={{ padding: 24, fontFamily: "ui-sans-serif, system-ui, sans-serif" }}>
        <h3>No agents yet</h3>
        <p style={{ color: "#6e6e80" }}>Create one to get started.</p>
        <button className="btn primary" onClick={addAgent}>+ New agent</button>
      </div>
    );
  }
  const currentWorkspace = [...runs].reverse().find((r) => r.workspace)?.workspace || null;

  return (
    <div className="app">
      <div className="topbar">
        <div className="brand">
          <div className="dot">百</div>
          baizhi
          <span style={{ color: "var(--text-faint)" }}>/</span>
          <span style={{ fontWeight: 500, color: "var(--text-muted)" }}>Playground</span>
        </div>
        <div className="tabs">
          <button className="active">Playground</button>
          <a href="/manage" style={tabLink}>Manage</a>
          <a href="/evals" style={tabLink}>Evals</a>
          <a href="/traces" style={tabLink}>Traces</a>
          <a href="/marketplace" style={tabLink}>Marketplace</a>
          <a href="/apps" style={tabLink}>Apps</a>
          {isAdmin && <a href="/users" style={tabLink}>Users</a>}
          <a href="/api-docs" style={tabLink}>API&nbsp;Docs</a>
        </div>
        <div className="spacer" />
        <div className="actions">
          <MarkdownDefaultPicker value={markdownDefaultMode} onChange={setMarkdownDefaultMode} />
        </div>
      </div>
      <div
        className="cols resizable"
        style={{ "--left-w": leftW + "px", "--right-w": rightW + "px" }}
      >
        <LeftPanel
          agents={agents}
          activeId={activeId}
          onSelect={selectAgent}
          onAdd={addAgent}
          onUpdateAgent={updateAgent}
          modelAgent={modelAgent}
          modelOverride={threadModelOverride}
          onUpdateModel={updateThreadModelConfig}
          onResetModel={resetThreadModelConfig}
        />
        <Gutter side="left" leftW={leftW} rightW={rightW} setLeftW={setLeftW} setRightW={setRightW} onReset={resetWidths} />
        <MidPanel
          agent={agent}
          messages={messages}
          onSend={send}
          onUpdateAgent={updateAgent}
          busy={busy}
          onStop={stop}
          onClear={clearThread}
          onThreadIdChange={changeThreadId}
          onHumanInput={submitHumanInput}
          onToolApproval={submitToolApproval}
          threadId={threadId}
          availableSkills={effectiveSkillItems}
          sessionSkills={sessionSkills}
          sessionMcps={sessionMcps}
          sessionLocalTools={sessionLocalTools}
          runContext={runContext}
          onRunContextChange={setRunContext}
          onToggleSessionSkill={toggleSessionSkill}
          onToggleSessionMcp={toggleSessionMcp}
          onToggleSessionLocalTool={toggleSessionLocalTool}
          markdownDefaultMode={markdownDefaultMode}
          historyError={historyError}
        />
        <Gutter side="right" leftW={leftW} rightW={rightW} setLeftW={setLeftW} setRightW={setRightW} onReset={resetWidths} />
        <RightPanel
          runs={runs}
          threadId={threadId}
          agent={agent}
          workspace={currentWorkspace}
          sessionSkills={sessionSkills}
          onLoadRunDetail={loadRunDetail}
          onLoadRunModelIO={loadRunModelIO}
        />
      </div>
    </div>
  );
}

const tabLink = {
  background: "transparent", border: 0, padding: "6px 10px",
  borderRadius: 6, color: "var(--text-muted)", fontSize: 13,
  textDecoration: "none", cursor: "pointer",
};

ReactDOM.createRoot(document.getElementById("root")).render(<App />);
