// Middle column: chat surface + composer with skill/MCP chips

function ToolInvocation({ inv }) {
  const skill = SKILLS.find((s) => s.id === inv.toolId) || MCPS.find((s) => s.id === inv.toolId);
  const color = skill?.color || "#0d0d0d";
  const initials = skill?.initials || "T";
  const [expanded, setExpanded] = React.useState(false);
  // One-line preview vs full pretty-print. We keep an inline preview
  // in the head so short args (load_skill {"skill_name":"..."}) stay
  // dense; long args (skill_write_file with a `content` blob) get
  // clamp+ellipsis with click-to-expand below.
  const argsCompact = "(" + JSON.stringify(inv.args) + ")";
  const argsPretty = JSON.stringify(inv.args, null, 2);
  const isLong = argsCompact.length > 80;
  const onToggle = (e) => {
    if (!isLong) return;
    e.stopPropagation();
    setExpanded((v) => !v);
  };
  return (
    <div className="tool-invocation">
      <div
        className={"head" + (isLong ? " expandable" : "") + (expanded ? " expanded" : "")}
        onClick={onToggle}
        title={isLong ? (expanded ? "Click to collapse args" : "Click to expand full args") : undefined}
      >
        <div className="ic" style={{ background: color }}>{initials}</div>
        <div className="nm">{inv.name}</div>
        {inv.round != null && (
          <span className="round-badge" title="LLM round that issued this tool call">
            round {inv.round}
          </span>
        )}
        <div className="args">{argsCompact}</div>
        {isLong && (
          <span className="args-chevron" aria-hidden="true">{expanded ? "▾" : "▸"}</span>
        )}
        <div className="dur">{fmtMs(inv.durMs)}</div>
      </div>
      {expanded && (
        <pre className="args-pretty">{argsPretty}</pre>
      )}
      {inv.preview && <div className="body">{inv.preview}</div>}
      {/* pr-tool-error-visibility-ui: when the tool errored, render the full
          result_text (backend caps at 1024 chars + "…[truncated, total N chars]").
          Without this the user only sees "× error (66 B)" and has to dig in
          /traces to find out WHAT failed. Plain <pre> + monospace so JSON /
          stack traces / "ERROR: script not found:" remain readable. */}
      {inv.isError && inv.resultText && (
        <pre className="tool-error-body">{inv.resultText}</pre>
      )}
    </div>
  );
}

// PR C: chip that resolves an asset's download URL on click + opens it.
// We don't pre-fetch the URL because each download URL may be short-lived
// (per `create_asset_download_url` semantics), and a chat with N file
// outputs shouldn't burn N network calls just to render.
function AssetDownloadChip({ asset }) {
  const [busy, setBusy] = React.useState(false);
  const sizeHuman = (() => {
    const s = asset.size || 0;
    if (s < 1024) return `${s} B`;
    if (s < 1024 * 1024) return `${(s / 1024).toFixed(1)} KB`;
    return `${(s / 1024 / 1024).toFixed(1)} MB`;
  })();
  const onClick = async (e) => {
    e.preventDefault();
    if (busy) return;
    setBusy(true);
    try {
      const { url } = await window.api.createAssetDownloadUrl(asset.asset_id);
      window.open(url, "_blank", "noopener,noreferrer");
    } catch (err) {
      alert("Download failed: " + (err.message || err));
    } finally {
      setBusy(false);
    }
  };
  return (
    <a
      href="#"
      className="msg-asset-chip"
      onClick={onClick}
      title={`${asset.filename} · ${asset.mime_type || "binary"} · ${sizeHuman}`}
    >
      <Icon name="upload" size={11} className="rotate-180" />
      <span className="nm">{asset.filename}</span>
      <span className="sz">{sizeHuman}</span>
    </a>
  );
}

// HITL (docs/HIL-design.md 场景1): the agent paused via `ask_human` and the
// run is `awaiting_input`. We render the requested fields as a small inline
// form inside the assistant bubble; submitting calls `onSubmit(values)` which
// (app.jsx) resumes the paused run with the human's answers. The composer is
// disabled meanwhile (busy gate), so this form is the only way forward.
function HumanInputForm({ spec, onSubmit }) {
  const fields = spec.fields || [];
  const [values, setValues] = React.useState(() =>
    Object.fromEntries(fields.map((f) => [f.name, ""]))
  );
  const [submitted, setSubmitted] = React.useState(false);
  const setField = (name, v) => setValues((prev) => ({ ...prev, [name]: v }));
  const submit = () => {
    if (submitted) return;
    setSubmitted(true);
    onSubmit(values);
  };
  const onKey = (e) => {
    if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); submit(); }
  };
  return (
    <div className="human-input">
      <div className="hi-head">
        <Icon name="wand" size={12} />
        <span>需要你的输入才能继续</span>
      </div>
      {spec.prompt && <div className="hi-prompt">{spec.prompt}</div>}
      <div className="hi-fields">
        {fields.map((f) => (
          <label key={f.name} className="hi-field">
            <span className="hi-label">{f.label || f.name}</span>
            <input
              type="text"
              value={values[f.name] ?? ""}
              placeholder={f.label || f.name}
              disabled={submitted}
              onChange={(e) => setField(f.name, e.target.value)}
              onKeyDown={onKey}
            />
          </label>
        ))}
      </div>
      <div className="hi-actions">
        <button className="btn primary" onClick={submit} disabled={submitted}>
          {submitted ? "继续中…" : fields.length ? "提交并继续" : "继续执行"} <Icon name="send" size={12} />
        </button>
      </div>
    </div>
  );
}

function ToolApprovalCard({ spec, onDecision }) {
  const interruptionType = spec?.interruption_type || spec?.kind || "tool_approval";
  if (spec?.submitted) {
    const dec = spec.submittedDecision;
    const reason = spec.submittedReason;
    const summary = dec === "reject"
      ? "已拒绝" + (reason ? `：${reason}` : "")
      : dec === "modify" ? "已修改并提交" : "已批准";
    const isForm = (spec.interruption_type === "form_submit");
    const form = spec?.content?.form_config || {};
    const fields = Array.isArray(form.data) ? form.data : [];
    const vals = spec.submittedValues || {};
    return (
      <div className="human-input tool-approval ta-submitted">
        <div className="hi-head">
          <Icon name={dec === "reject" ? "x" : "check"} size={12} />
          <span>{isForm ? (form.title || "补充信息") : summary}</span>
          {isForm && (
            <span className="dim" style={{ marginLeft: "auto", fontSize: 11 }}>{summary}</span>
          )}
        </div>
        {isForm && fields.length > 0 && dec !== "reject" && (
          <div className="ta-submitted-values" style={{ marginTop: 8, fontSize: 12, color: "#555" }}>
            {fields.map((f, i) => {
              const key = f.parameter || f.name || f.title || `field_${i}`;
              const v = vals[key];
              if (!v) return null;
              const selectedValues = Array.isArray(v) ? v : [v];
              const selectedLabels = selectedValues.map(
                (value) => Array.isArray(f.options)
                  ? (f.options.find((o) => o.value === value)?.label || value)
                  : value,
              );
              return (
                <div key={key} style={{ padding: "2px 0" }}>
                  <span style={{ color: "#888" }}>{f.title || key}：</span>
                  <span>{selectedLabels.join("、")}</span>
                </div>
              );
            })}
          </div>
        )}
      </div>
    );
  }
  if (interruptionType === "form_submit") {
    return <FormSubmitCard spec={spec} onDecision={onDecision} />;
  }
  if (interruptionType === "guider_template_approval") {
    return <OutlineApprovalCard spec={spec} onDecision={onDecision} />;
  }
  const argsText = spec?.content?.arguments_preview || "{}";
  const tool = spec?.tool || {};
  const [editedArgs, setEditedArgs] = React.useState(argsText);
  const [reason, setReason] = React.useState("");
  const [submitted, setSubmitted] = React.useState(false);
  const decide = (decision) => {
    if (submitted) return;
    let modifiedArgs = null;
    if (decision === "modify") {
      try {
        modifiedArgs = JSON.parse(editedArgs || "{}");
      } catch (err) {
        alert("修改后的参数不是合法 JSON");
        return;
      }
    }
    setSubmitted(true);
    onDecision?.(decision, {
      modified_args: modifiedArgs,
      rejection_reason: reason || undefined,
    });
  };
  return (
    <div className="human-input tool-approval">
      <div className="hi-head">
        <Icon name="stop" size={12} />
        <span>工具调用需要审批</span>
      </div>
      <div className="hi-prompt">
        <strong>{tool.name || spec?.content?.tool_name || "tool"}</strong>
        {tool.source ? <span className="ta-meta"> · {tool.source}{tool.owner_id ? `/${tool.owner_id}` : ""}</span> : null}
      </div>
      <textarea
        className="ta-json"
        value={editedArgs}
        disabled={submitted}
        spellCheck={false}
        onChange={(e) => setEditedArgs(e.target.value)}
      />
      <input
        className="ta-reason"
        type="text"
        value={reason}
        disabled={submitted}
        placeholder="拒绝原因，可选"
        onChange={(e) => setReason(e.target.value)}
      />
      <div className="hi-actions">
        <button className="btn ghost" onClick={() => decide("reject")} disabled={submitted}>拒绝</button>
        <button className="btn" onClick={() => decide("modify")} disabled={submitted}>修改后批准</button>
        <button className="btn primary" onClick={() => decide("approve")} disabled={submitted}>批准</button>
      </div>
    </div>
  );
}

function FormSubmitCard({ spec, onDecision }) {
  const form = spec?.content?.form_config || spec?.data || {};
  const fields = Array.isArray(form.data) ? form.data : [];
  const [values, setValues] = React.useState(() => {
    const init = {};
    fields.forEach((f) => {
      const key = f.parameter || f.name || f.title;
      const hasOptions = Array.isArray(f.options) && f.options.length > 0;
      init[key] = f.type === "checkbox" && hasOptions
        ? (Array.isArray(f.value) ? f.value : (f.value ? [f.value] : []))
        : (f.value || "");
    });
    return init;
  });
  const [submitted, setSubmitted] = React.useState(false);
  const setField = (key, value) => setValues((prev) => ({ ...prev, [key]: value }));
  const toggleCheckboxOption = (key, value, checked) => {
    setValues((prev) => {
      const current = Array.isArray(prev[key]) ? prev[key] : [];
      const next = checked
        ? [...new Set([...current, value])]
        : current.filter((item) => item !== value);
      return { ...prev, [key]: next };
    });
  };
  const submit = () => {
    if (submitted) return;
    setSubmitted(true);
    onDecision?.("modify", { modified_args: values });
  };
  return (
    <div className="human-input tool-approval">
      <div className="hi-head">
        <Icon name="wand" size={12} />
        <span>{form.title || "补充信息"}</span>
      </div>
      {spec?.content?.prefix && <div className="hi-prompt">{spec.content.prefix}</div>}
      <div className="hi-fields">
        {fields.map((f, i) => {
          const key = f.parameter || f.name || f.title || `field_${i}`;
          const type = f.type || "input";
          const options = Array.isArray(f.options) ? f.options : [];
          return (
            <div key={key} className="hi-field">
              <span className="hi-label">{f.title || key}{f.required ? " *" : ""}</span>
              {type === "textarea" ? (
                <textarea
                  className="ta-json"
                  value={values[key] || ""}
                  disabled={submitted}
                  placeholder={f.placeholder || ""}
                  onChange={(e) => setField(key, e.target.value)}
                />
              ) : type === "select" || type === "radio" ? (
                <select
                  className="select"
                  value={values[key] || ""}
                  disabled={submitted}
                  onChange={(e) => setField(key, e.target.value)}
                >
                  <option value="">请选择</option>
                  {options.map((op) => (
                    <option key={op.value ?? op.label} value={op.value ?? op.label} title={op.description || ""}>{op.label ?? op.value}</option>
                  ))}
                </select>
              ) : type === "checkbox" && options.length > 0 ? (
                <div className="hi-checkbox-options">
                  {options.map((op) => {
                    const value = op.value ?? op.label;
                    const selected = Array.isArray(values[key]) && values[key].includes(value);
                    return (
                      <label key={value} className="hi-checkbox-option">
                        <input
                          type="checkbox"
                          checked={selected}
                          disabled={submitted}
                          onChange={(e) => toggleCheckboxOption(key, value, e.target.checked)}
                        />
                        <span>{op.label ?? value}</span>
                      </label>
                    );
                  })}
                </div>
              ) : type === "checkbox" ? (
                <label style={{ display: "flex", alignItems: "center", gap: 6 }}>
                  <input
                    type="checkbox"
                    checked={!!values[key]}
                    disabled={submitted}
                    onChange={(e) => setField(key, e.target.checked)}
                  />
                  <span>{f.placeholder || ""}</span>
                </label>
              ) : (
                <input
                  type="text"
                  value={values[key] || ""}
                  disabled={submitted}
                  maxLength={f.maxLength || undefined}
                  placeholder={f.placeholder || ""}
                  onChange={(e) => setField(key, e.target.value)}
                />
              )}
            </div>
          );
        })}
      </div>
      <div className="hi-actions">
        <button className="btn ghost" onClick={() => onDecision?.("reject", { rejection_reason: "用户拒绝填写表单" })} disabled={submitted}>拒绝</button>
        <button className="btn primary" onClick={submit} disabled={submitted}>
          {submitted ? "继续中…" : "提交并继续"} <Icon name="send" size={12} />
        </button>
      </div>
    </div>
  );
}

function OutlineApprovalCard({ spec, onDecision }) {
  const content = spec?.content || {};
  const options = content.outline_options || {};
  const uiOptions = spec?.ui_config?.selection_options || Object.keys(options).map((key) => ({
    key,
    label: options[key]?.name || key,
  }));
  const [selected, setSelected] = React.useState(uiOptions[0]?.key || "");
  const [customJson, setCustomJson] = React.useState("");
  const [submitted, setSubmitted] = React.useState(false);
  const submit = () => {
    if (submitted) return;
    let modifiedArgs = {};
    if (selected === "custom") {
      try {
        modifiedArgs = { final_outline: JSON.parse(customJson || "{}") };
      } catch (err) {
        alert("自定义大纲不是合法 JSON");
        return;
      }
    } else {
      modifiedArgs = { selected_outline: selected };
    }
    setSubmitted(true);
    onDecision?.("modify", { modified_args: modifiedArgs });
  };
  const reject = () => {
    if (submitted) return;
    setSubmitted(true);
    onDecision?.("reject", { rejection_reason: "用户拒绝选择大纲" });
  };
  return (
    <div className="human-input tool-approval">
      <div className="hi-head">
        <Icon name="list" size={12} />
        <span>请选择报告大纲</span>
      </div>
      <div className="hi-prompt">
        <strong>{content.task_title || "大纲确认"}</strong>
        {content.report_type ? <span className="ta-meta"> · {content.report_type}</span> : null}
      </div>
      <div className="hi-fields">
        {uiOptions.map((op) => {
          const optData = options[op.key] || {};
          const advantages = Array.isArray(optData.advantages) ? optData.advantages : [];
          return (
            <label key={op.key}>
              <span>
                <input
                  type="radio"
                  checked={selected === op.key}
                  disabled={submitted}
                  onChange={() => setSelected(op.key)}
                />{" "}
                {op.label}
                {optData.recommended && (
                  <span style={{
                    display: "inline-block",
                    marginLeft: 6,
                    padding: "1px 6px",
                    fontSize: 10,
                    fontWeight: 600,
                    color: "#fff",
                    background: "#10a37f",
                    borderRadius: 4,
                    verticalAlign: "middle"
                  }}>推荐</span>
                )}
              </span>
              {optData.description && <span className="muted">{optData.description}</span>}
              {advantages.length > 0 && (
                <span className="muted" style={{ display: "block", fontSize: 11, marginTop: 2, color: "#888" }}>
                  {advantages.map((adv, j) => (
                    <span key={j} style={{ display: "block" }}>· {adv}</span>
                  ))}
                </span>
              )}
            </label>
          );
        })}
      </div>
      {selected === "custom" && (
        <textarea
          className="ta-json"
          value={customJson}
          disabled={submitted}
          spellCheck={false}
          placeholder='{"name":"自定义方案","outline":[{"title":"..."}]}'
          onChange={(e) => setCustomJson(e.target.value)}
        />
      )}
      <div className="hi-actions">
        <button className="btn ghost" onClick={reject} disabled={submitted}>拒绝</button>
        <button className="btn primary" onClick={submit} disabled={submitted}>确认选择</button>
      </div>
    </div>
  );
}

function escapeHtml(value) {
  return String(value ?? "")
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#39;");
}

function inlineMarkdown(text) {
  let s = escapeHtml(text);
  s = s.replace(/!\[([^\]]*)\]\((https?:\/\/[^)\s]+)\)/g, (_m, alt, url) => (
    `<img src="${url}" alt="${alt}" loading="lazy" referrerpolicy="no-referrer" />`
  ));
  s = s.replace(/`([^`]+)`/g, "<code>$1</code>");
  s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
  s = s.replace(/\*([^*\n]+)\*/g, "<em>$1</em>");
  s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
  return s;
}

function isMarkdownTableDivider(line) {
  return /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line || "");
}

function renderMarkdownTable(lines) {
  const rows = lines.map((line) => line.trim().replace(/^\||\|$/g, "").split("|").map((cell) => cell.trim()));
  const head = rows[0] || [];
  const hasDivider = lines.length > 1 && isMarkdownTableDivider(lines[1]);
  const body = rows.slice(hasDivider ? 2 : 1);
  return (
    "<table><thead><tr>" +
    head.map((cell) => `<th>${inlineMarkdown(cell)}</th>`).join("") +
    "</tr></thead><tbody>" +
    body.map((row) => (
      "<tr>" + row.map((cell) => `<td>${inlineMarkdown(cell)}</td>`).join("") + "</tr>"
    )).join("") +
    "</tbody></table>"
  );
}

function renderMarkdownPreview(md) {
  const lines = String(md || "").replace(/\r\n/g, "\n").split("\n");
  const out = [];
  let para = [];
  let list = [];
  let code = null;
  let table = [];
  let quote = [];

  const flushPara = () => {
    if (!para.length) return;
    out.push(`<p>${inlineMarkdown(para.join(" "))}</p>`);
    para = [];
  };
  const flushList = () => {
    if (!list.length) return;
    out.push("<ul>" + list.map((item) => `<li>${inlineMarkdown(item)}</li>`).join("") + "</ul>");
    list = [];
  };
  const flushTable = () => {
    if (!table.length) return;
    out.push(renderMarkdownTable(table));
    table = [];
  };
  const flushQuote = () => {
    if (!quote.length) return;
    out.push("<blockquote>" + quote.map((item) => `<p>${inlineMarkdown(item)}</p>`).join("") + "</blockquote>");
    quote = [];
  };
  const flushBlocks = () => { flushPara(); flushList(); flushTable(); flushQuote(); };

  for (const raw of lines) {
    const line = raw.replace(/\s+$/g, "");
    if (code) {
      if (/^```/.test(line)) {
        out.push(`<pre><code>${escapeHtml(code.lines.join("\n"))}</code></pre>`);
        code = null;
      } else {
        code.lines.push(raw);
      }
      continue;
    }
    const fence = /^```(\w+)?/.exec(line);
    if (fence) {
      flushBlocks();
      code = { lang: fence[1] || "", lines: [] };
      continue;
    }
    if (!line.trim()) {
      flushBlocks();
      continue;
    }
    if (/^\|.+\|$/.test(line)) {
      flushPara(); flushList(); flushQuote();
      table.push(line);
      continue;
    }
    flushTable();
    const blockquote = /^>\s?(.*)$/.exec(line);
    if (blockquote) {
      flushPara(); flushList();
      quote.push(blockquote[1]);
      continue;
    }
    flushQuote();
    const heading = /^(#{1,4})\s+(.+)$/.exec(line);
    if (heading) {
      flushPara(); flushList();
      const level = heading[1].length;
      out.push(`<h${level}>${inlineMarkdown(heading[2])}</h${level}>`);
      continue;
    }
    const bullet = /^\s*[-*]\s+(.+)$/.exec(line);
    if (bullet) {
      flushPara();
      list.push(bullet[1]);
      continue;
    }
    para.push(line.trim());
  }
  if (code) out.push(`<pre><code>${escapeHtml(code.lines.join("\n"))}</code></pre>`);
  flushBlocks();
  return out.join("");
}

function MarkdownPreview({ content }) {
  const html = React.useMemo(() => renderMarkdownPreview(content || ""), [content]);
  return <div className="markdown-preview" dangerouslySetInnerHTML={{ __html: html }} />;
}

function ReasoningBlock({ reasoning }) {
  const content = reasoning?.content || "";
  if (!reasoning?.active && !content.trim()) return null;
  return (
    <div className={"reasoning-block" + (reasoning?.active ? " active" : "")}>
      <div className="reasoning-head">
        <Icon name="cpu" size={12} />
        <span>{reasoning?.active ? "Thinking" : "Thinking trace"}</span>
      </div>
      <div className="reasoning-body">
        {content.trim() ? content : <span className="typing"><span></span><span></span><span></span></span>}
      </div>
    </div>
  );
}

function ProcessInfoBlock({ invocations }) {
  const items = invocations || [];
  const [open, setOpen] = React.useState(false);
  if (!items.length) return null;
  const running = items.filter((inv) => inv.running).length;
  const errors = items.filter((inv) => inv.isError).length;
  const label = running
    ? `${items.length} step${items.length === 1 ? "" : "s"} · ${running} running`
    : `${items.length} step${items.length === 1 ? "" : "s"}`;
  return (
    <div className={"process-block" + (open ? " open" : "")}>
      <button
        type="button"
        className="process-head"
        onClick={() => setOpen((v) => !v)}
        aria-expanded={open}
        title={open ? "Collapse process details" : "Expand process details"}
      >
        <Icon name={open ? "chevdown" : "chev"} size={13} />
        <span>Process</span>
        <span className="process-meta">{label}</span>
        {errors > 0 && <span className="process-error">{errors} error{errors === 1 ? "" : "s"}</span>}
      </button>
      {open && (
        <div className="process-body">
          {items.map((inv, i) => <ToolInvocation key={`${inv.name}-${i}`} inv={inv} />)}
        </div>
      )}
    </div>
  );
}

function Message({ msg, agent, onHumanInput, onToolApproval, markdownDefaultMode }) {
  const [previewOverride, setPreviewOverride] = React.useState(null);
  const defaultPreview = markdownDefaultMode === "preview";
  const previewMarkdown = previewOverride ?? defaultPreview;
  const canPreview = msg.role !== "user" && !msg.streaming && !!(msg.content || "").trim();
  if (msg.role === "user") {
    return (
      <div className="msg">
        <div className="av user">U</div>
        <div className="body">
          <div className="who">You<span className="when">{msg.time}</span></div>
          <div className="content">{msg.content}</div>
        </div>
      </div>
    );
  }
  // assistant
  const statusKind = msg.status && msg.status !== "completed" ? msg.status : null;
  const invocations = msg.toolInvocations || [];
  const anchorIdx = msg.toolApprovalAt ?? -1;
  const hasSubmittedCard = msg.toolApproval?.submitted && anchorIdx >= 0;
  return (
    <div className="msg">
      <div className="av" style={{ background: agent?.color || "#10a37f" }}>
        {agent?.initials || "A"}
      </div>
      <div className="body">
        <div className="who">
          {agent?.name || "Assistant"}<span className="when">{msg.time}</span>
          <div className="msg-actions">
            {canPreview && (
              <button
                className={"btn ghost sm markdown-toggle" + (previewMarkdown ? " active" : "")}
                onClick={() => setPreviewOverride((v) => !(v ?? defaultPreview))}
                title={previewMarkdown ? "Show raw markdown text" : "Preview this reply as Markdown"}
              >
                <Icon name={previewMarkdown ? "code" : "list"} size={11} />
                {previewMarkdown ? "Source" : "Preview"}
              </button>
            )}
          </div>
        </div>
        {/* Inline alert banners — populated from non-fatal events the user
            should notice (currently just `agent.max_rounds_hit`). */}
        {(msg.alerts || []).map((a, i) => (
          <div key={i} className={`msg-alert ${a.level || "warn"}`}>
            <Icon name={a.level === "error" ? "x" : "wand"} size={11} />
            <span>{a.text}</span>
          </div>
        ))}
        <ReasoningBlock reasoning={msg.reasoning} />
        <ProcessInfoBlock invocations={invocations} />
        {hasSubmittedCard && (
          <ToolApprovalCard
            spec={msg.toolApproval}
            onDecision={(decision, detail) => onToolApproval?.(decision, detail)}
          />
        )}
        <div className="content" style={{ marginTop: invocations.length ? 8 : 0 }}>
          {msg.streaming && !msg.content
            ? <span className="typing"><span></span><span></span><span></span></span>
            : previewMarkdown
              ? <MarkdownPreview content={msg.content} />
              : msg.content}
        </div>
        {/* HITL: inline human-input form when the run paused on `ask_human`. */}
        {msg.awaitingInput && (
          <HumanInputForm
            spec={msg.awaitingInput}
            onSubmit={(values) => onHumanInput?.(values)}
          />
        )}
        {/* Live (non-submitted) tool approval card stays at the bottom. */}
        {msg.toolApproval && !msg.toolApproval.submitted && (
          <ToolApprovalCard
            spec={msg.toolApproval}
            onDecision={(decision, detail) => onToolApproval?.(decision, detail)}
          />
        )}
        {/* PR C: download links for any output assets the run produced
            (PPT / DOCX / XLSX / arbitrary files written under
            workspace/output/ by run_script). result.md is
            excluded by the producer — its content is `msg.content`. */}
        {!msg.streaming && (msg.outputAssets || []).length > 0 && (
          <div className="msg-assets">
            {msg.outputAssets.map((a) => (
              <AssetDownloadChip key={a.asset_id} asset={a} />
            ))}
          </div>
        )}
        {/* Activated-skills footer — small muted line below the reply so
            the user can see which skills the run actually loaded. */}
        {!msg.streaming && (msg.activatedSkills || []).length > 0 && (
          <div className="msg-skills">
            <Icon name="wand" size={10} />
            <span>skills used: {msg.activatedSkills.join(", ")}</span>
          </div>
        )}
        {!msg.streaming && msg.stats && (
          <div className="stats">
            {statusKind && (
              <>
                <span className={`stat status-${statusKind}`}>
                  <Icon name={statusKind === "failed" ? "x" : "stop"} size={11} />
                  {statusKind}
                </span>
                <span className="sep">·</span>
              </>
            )}
            <span className="stat"><Icon name="loop" size={11} />{msg.stats.llmRounds} model {msg.stats.llmRounds === 1 ? "call" : "calls"}</span>
            <span className="sep">·</span>
            <span className="stat"><Icon name="tool" size={11} />{msg.stats.toolCalls} tool {msg.stats.toolCalls === 1 ? "call" : "calls"}</span>
            <span className="sep">·</span>
            <span className="stat"><Icon name="clock" size={11} />{fmtMs(msg.stats.totalMs)}</span>
            <span className="sep">·</span>
            <span className="stat"><Icon name="cpu" size={11} />{msg.stats.tokensIn}↑ {msg.stats.tokensOut}↓ tokens</span>
          </div>
        )}
        {msg.errorText && (
          <div className="msg-error">{msg.errorText}</div>
        )}
      </div>
    </div>
  );
}

function ComposerChip({ item, kind, onRemove }) {
  return (
    <span className="chip active" title={item.name}>
      <span className="ic" style={{ background: item.color }}>{item.initials}</span>
      {item.name}
      <span className="x" onClick={(e) => { e.stopPropagation(); onRemove(); }}>
        <Icon name="x" size={10} />
      </span>
    </span>
  );
}

// ChipPicker only offers tools configured on the current agent. It still
// mutates session state only; Manage is where agent configuration lives.
function ChipPicker({ availableSkills, availableMcps, availableLocalTools, sessionSkills, sessionMcps, sessionLocalTools, onToggleSkill, onToggleMcp, onToggleLocalTool }) {
  const [open, setOpen] = React.useState(false);
  const ref = React.useRef(null);
  React.useEffect(() => {
    const close = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
    document.addEventListener("mousedown", close);
    return () => document.removeEventListener("mousedown", close);
  }, []);
  return (
    <span className="rel" ref={ref}>
      <button className="chip-add" onClick={() => setOpen(!open)}>
        <Icon name="plus" size={10} /> Tools
      </button>
      {open && (
        <div className="composer-popover">
          <div className="group-label">Skills</div>
          {(availableSkills || []).length === 0 && (
            <div className="popover-empty">No skills configured on this agent.</div>
          )}
          {(availableSkills || []).map((s, i) => {
            const on = sessionSkills.includes(s.id);
            return (
              <div key={`${s.skill_id || s.id}-${i}`} className={"popover-row" + (on ? " on" : "")} onClick={() => onToggleSkill(s.id)}>
                <span className="ic" style={{
                  width: 16, height: 16, borderRadius: 4, display: "grid", placeItems: "center",
                  background: s.color, color: "white", fontSize: 10
                }}>{s.initials}</span>
                <span>{s.name}</span>
                {s.draft_status === "draft" || s.version === "draft" ? (
                  <span className="popover-badge">draft</span>
                ) : null}
                <span className="check"><Icon name="check" size={12} /></span>
              </div>
            );
          })}
          <div className="group-label" style={{ marginTop: 4 }} title="Only MCP servers configured on this agent can be selected for this chat.">MCP Servers</div>
          {(availableMcps || []).length === 0 && (
            <div className="popover-empty">No MCP servers configured on this agent.</div>
          )}
          {(availableMcps || []).map((s) => {
            const on = sessionMcps.includes(s.id);
            return (
              <div key={s.id} className={"popover-row" + (on ? " on" : "")} onClick={() => onToggleMcp(s.id)}>
                <span className="ic" style={{
                  width: 16, height: 16, borderRadius: 4, display: "grid", placeItems: "center",
                  background: s.color, color: "white", fontSize: 10
                }}>{s.initials}</span>
                <span>{s.name}</span>
                <span className="check"><Icon name="check" size={12} /></span>
              </div>
            );
          })}
          <div className="group-label" style={{ marginTop: 4 }} title="Only local tools configured on this agent can be selected for this chat.">Local Tools</div>
          {(availableLocalTools || []).length === 0 && (
            <div className="popover-empty">No local tools configured on this agent.</div>
          )}
          {(availableLocalTools || []).map((s) => {
            const on = (sessionLocalTools || []).includes(s.id);
            return (
              <div key={s.id} className={"popover-row" + (on ? " on" : "")} onClick={() => onToggleLocalTool(s.id)}>
                <span className="ic" style={{
                  width: 16, height: 16, borderRadius: 4, display: "grid", placeItems: "center",
                  background: s.color, color: "white", fontSize: 10
                }}>{s.initials}</span>
                <span>{s.label || s.name}</span>
                <span className="check"><Icon name="check" size={12} /></span>
              </div>
            );
          })}
        </div>
      )}
    </span>
  );
}

const RUN_CONTEXT_BUILTIN_VARIABLES = new Set([
  "current_time", "thread_id", "run_id", "agent_id", "agent_name",
  "skill_list", "mcp_list", "attachments", "context",
]);

function isRunContextVariableName(name) {
  return /^[A-Za-z_][A-Za-z0-9_]*$/.test(String(name || "").trim());
}

function RunContextPanel({ value, onContextChange }) {
  const ctx = value || {};
  const [temporaryName, setTemporaryName] = React.useState("");
  const fields = React.useMemo(() => {
    const seen = new Set();
    const out = [];
    for (const raw of Object.keys(ctx)) {
      const name = String(raw || "").trim();
      if (!isRunContextVariableName(name) || RUN_CONTEXT_BUILTIN_VARIABLES.has(name) || seen.has(name)) continue;
      seen.add(name);
      out.push(name);
    }
    return out;
  }, [JSON.stringify(Object.keys(ctx).sort())]);
  const filledCount = Object.values(ctx).filter((v) => String(v || "").trim()).length;
  const setField = (key, next) => {
    onContextChange?.({ ...ctx, [key]: next });
  };
  const removeField = (key) => {
    const next = { ...ctx };
    delete next[key];
    onContextChange?.(next);
  };
  const addVariable = () => {
    const name = temporaryName.trim();
    if (!isRunContextVariableName(name) || RUN_CONTEXT_BUILTIN_VARIABLES.has(name)) return;
    onContextChange?.({ ...ctx, [name]: ctx[name] || "" });
    setTemporaryName("");
  };
  return (
    <div className="run-context-panel">
      <div className="run-context-head">
        <Icon name="tool" size={12} />
        <span>Run Context</span>
        <em>Sent as options.context for prompt variables</em>
        <b>{filledCount ? `${filledCount} vars` : "No runtime context"}</b>
      </div>
      <div className="run-context-list">
        {fields.length ? fields.map((name) => (
          <label className="run-context-row" key={name}>
            <code title={name}>{name}</code>
            <input
              className="input mono"
              value={ctx[name] || ""}
              placeholder="value for this run"
              onChange={(e) => setField(name, e.target.value)}
            />
            <button type="button" className="btn ghost icon sm" onClick={() => removeField(name)} title={`Remove ${name}`}>
              <Icon name="x" size={12} />
            </button>
          </label>
        )) : (
          <div className="run-context-empty">No context variables for this run</div>
        )}
        <div className="run-context-add-row">
          <input
            className="input mono"
            value={temporaryName}
            placeholder="custom_variable"
            onChange={(e) => setTemporaryName(e.target.value)}
            onKeyDown={(e) => {
              if (e.key === "Enter") {
                e.preventDefault();
                addVariable();
              }
            }}
          />
          <button
            type="button"
            className="btn sm"
            onClick={addVariable}
            disabled={!isRunContextVariableName(temporaryName) || RUN_CONTEXT_BUILTIN_VARIABLES.has(temporaryName.trim())}
          >
            + Variable
          </button>
        </div>
      </div>
    </div>
  );
}

const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;

function formatUploadSize(bytes) {
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

function Composer({ agent, onSend, busy, onStop,
                    availableSkills,
                    sessionSkills, sessionMcps, sessionLocalTools,
                    runContext, onRunContextChange,
                    onToggleSessionSkill, onToggleSessionMcp, onToggleSessionLocalTool }) {
  const [text, setText] = React.useState("");
  // PR chat-uploads-workspace: pending attachments (each = {asset_id,
  // filename, mime_type, size}) collected before Send. Cleared after a
  // successful submit. Cap mirrors backend uploads.MAX_ATTACHMENTS_PER_RUN.
  const [attachments, setAttachments] = React.useState([]);
  const [uploading, setUploading] = React.useState(false);
  const ref = React.useRef(null);
  const fileInputRef = React.useRef(null);
  const submit = () => {
    const v = text.trim();
    if (!v || busy || uploading) return;
    // 3rd arg: array of asset_ids to attach
    onSend(v, attachments.map((a) => a.asset_id));
    setText("");
    setAttachments([]);
    if (ref.current) ref.current.style.height = "44px";
  };
  const pickFiles = () => fileInputRef.current && fileInputRef.current.click();
  const onFilesPicked = async (e) => {
    const files = Array.from(e.target.files || []);
    e.target.value = "";  // allow re-selecting the same file later
    if (!files.length) return;
    if (attachments.length + files.length > 10) {
      alert("Up to 10 attachments per message.");
      return;
    }
    setUploading(true);
    try {
      for (const f of files) {
        if (f.size > MAX_UPLOAD_BYTES) {
          alert(
            `Upload failed (${f.name}): file is too large ` +
            `(${formatUploadSize(f.size)}); max ${formatUploadSize(MAX_UPLOAD_BYTES)}`
          );
          continue;
        }
        try {
          const ref = await window.api.uploadAttachment(f);
          setAttachments((prev) => [...prev, ref]);
        } catch (err) {
          alert(`Upload failed (${f.name}): ${err.message || err}`);
        }
      }
    } finally {
      setUploading(false);
    }
  };
  const removeAttachment = (asset_id) => {
    setAttachments((prev) => prev.filter((a) => a.asset_id !== asset_id));
  };
  const onKey = (e) => {
    // Plain Enter keeps the textarea's default newline behavior.
    if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
      e.preventDefault();
      submit();
    }
  };
  const onInput = (e) => {
    setText(e.target.value);
    const t = e.target;
    t.style.height = "44px";
    t.style.height = Math.min(200, t.scrollHeight) + "px";
  };

  const agentMcpIds = new Set(agent.enabledMCPs || []);
  const agentLocalToolIds = new Set(agent.enabledLocalTools || agent.localToolNames || []);
  const availableSkillItems = availableSkills || [];
  const availableMcpItems = MCPS.filter((s) => agentMcpIds.has(s.id));
  const availableLocalToolItems = (window.LOCAL_TOOLS || []).filter((s) => agentLocalToolIds.has(s.id));
  const sessionSkillItems = availableSkillItems.filter((s) => (sessionSkills || []).includes(s.id));
  const sessionMcpItems = availableMcpItems.filter((s) => (sessionMcps || []).includes(s.id));
  const sessionLocalToolItems = availableLocalToolItems.filter((s) => (sessionLocalTools || []).includes(s.id));

  return (
    <div className="composer-wrap">
      <RunContextPanel key={agent.id} value={runContext} onContextChange={onRunContextChange} />
      <div className="composer">
        <textarea
          ref={ref}
          placeholder={`Message ${agent.name}…  (Ctrl/⌘ + ↵ send · Shift+Enter for newline)`}
          value={text}
          onChange={onInput}
          onKeyDown={onKey}
        />
        <div className="composer-bottom">
          <div className="chips">
            {/* PR chat-uploads-workspace: attachment chips rendered FIRST so
                they're visible above the skill/MCP picker. Each shows name +
                size; × removes from pending list (does NOT delete the
                uploaded asset on the server — server GCs unconsumed after
                24h). */}
            {attachments.map((a) => (
              <ComposerChip
                key={a.asset_id}
                item={{
                  name: `${a.filename} · ${Math.max(1, Math.round(a.size / 1024))} KB`,
                  color: "#6b7280",       // gray — distinguishes from skill colors
                  initials: a.filename.startsWith(".") ? "•" : a.filename[0].toUpperCase(),
                }}
                kind="file"
                onRemove={() => removeAttachment(a.asset_id)}
              />
            ))}
            {sessionSkillItems.map((s, i) => (
              <ComposerChip
                key={`${s.skill_id || s.id}-${i}`}
                item={s} kind="skill"
                onRemove={() => onToggleSessionSkill(s.id)}
              />
            ))}
            {sessionMcpItems.map((s) => (
              <ComposerChip
                key={s.id}
                item={s} kind="mcp"
                onRemove={() => onToggleSessionMcp(s.id)}
              />
            ))}
            {sessionLocalToolItems.map((s) => (
              <ComposerChip
                key={s.id}
                item={{ ...s, name: s.label || s.name }} kind="local_tool"
                onRemove={() => onToggleSessionLocalTool(s.id)}
              />
            ))}
            <ChipPicker
              availableSkills={availableSkillItems}
              availableMcps={availableMcpItems}
              availableLocalTools={availableLocalToolItems}
              sessionSkills={sessionSkills || []}
              sessionMcps={sessionMcps || []}
              sessionLocalTools={sessionLocalTools || []}
              onToggleSkill={onToggleSessionSkill}
              onToggleMcp={onToggleSessionMcp}
              onToggleLocalTool={onToggleSessionLocalTool}
            />
            {/* Attach button + hidden file input. Multi-select OK; backend
                caps at 10 per send. */}
            <button
              className="chip dim"
              type="button"
              onClick={pickFiles}
              disabled={uploading || busy}
              title="Attach files (up to 10, 25 MB each)"
              style={{ cursor: "pointer" }}
            >
              <Icon name="upload" size={11} />{" "}
              {uploading ? "Uploading…" : "Attach"}
            </button>
            <input
              ref={fileInputRef}
              type="file"
              multiple
              hidden
              onChange={onFilesPicked}
            />
          </div>
          {busy ? (
            <button className="btn" onClick={onStop} title="Stop">
              <Icon name="stop" size={11} /> Stop
            </button>
          ) : (
            <button
              className="btn primary"
              onClick={submit}
              disabled={!text.trim() || uploading}
            >
              Send <Icon name="send" size={12} />
            </button>
          )}
        </div>
      </div>
      <div className="row" style={{ justifyContent: "flex-end", marginTop: 6, gap: 10 }}>
        <span className="dim mono" style={{ fontSize: 10.5 }}>
          <span className="kbd">Ctrl/⌘ + ↵</span> send &nbsp;
          <span className="kbd">⇧⏎</span> newline
        </span>
      </div>
    </div>
  );
}

function MidPanel({ agent, messages, onSend, onUpdateAgent, busy, onStop, onClear, onHumanInput, onToolApproval, threadId,
                    onThreadIdChange,
                    availableSkills,
                    sessionSkills, sessionMcps, sessionLocalTools,
                    runContext, onRunContextChange,
                    onToggleSessionSkill, onToggleSessionMcp, onToggleSessionLocalTool,
                    markdownDefaultMode, historyError }) {
  const scrollRef = React.useRef(null);
  const [threadIdDraft, setThreadIdDraft] = React.useState(threadId);
  React.useEffect(() => setThreadIdDraft(threadId), [threadId]);
  React.useEffect(() => {
    if (scrollRef.current) {
      scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
    }
  }, [messages.length, messages[messages.length - 1]?.content, messages[messages.length - 1]?.streaming]);

  const commitThreadId = () => {
    const committed = onThreadIdChange(threadIdDraft);
    setThreadIdDraft(committed || threadId);
  };

  return (
    <div className="col mid">
      <div className="chat-head">
        <div className="title">
          <div style={{
            width: 22, height: 22, borderRadius: 6,
            background: agent.color, display: "grid", placeItems: "center",
            color: "white", fontWeight: 700, fontSize: 11
          }}>{agent.initials}</div>
          {agent.name}
        </div>
        <span className="badge">{agent.model}</span>
        <label className="thread-id-field" title="输入 Thread ID；留空会自动生成">
          <span>thread</span>
          <input
            value={threadIdDraft}
            onChange={(e) => setThreadIdDraft(e.target.value)}
            onBlur={commitThreadId}
            onKeyDown={(e) => {
              if (e.key === "Enter") e.currentTarget.blur();
            }}
            disabled={busy}
            spellCheck={false}
            aria-label="Thread ID"
            placeholder="自动生成"
          />
        </label>
        <div className="spacer" />
        <button className="btn ghost sm" onClick={onClear} title="Clear thread">
          <Icon name="trash" size={12} /> Clear
        </button>
      </div>
      <div className="scroll" ref={scrollRef}>
        {historyError && (
          <div className="history-load-error" role="alert">
            <span>{historyError}</span>
            <button onClick={() => window.location.reload()}>重试</button>
          </div>
        )}
        {messages.length === 0 ? (
          <div className="empty">
            <div className="icon"><Icon name="wand" size={20} /></div>
            <h3>开始与 {agent.name} 对话</h3>
            <p>选择 Skills / MCP，输入问题。每一轮的工具调用与 AG-UI 事件会出现在右侧。</p>
          </div>
        ) : (
          <div className="messages">
            {messages.map((m) => (
              <Message
                key={m.id}
                msg={m}
                agent={agent}
                onHumanInput={onHumanInput}
                onToolApproval={onToolApproval}
                markdownDefaultMode={markdownDefaultMode}
              />
            ))}
          </div>
        )}
      </div>
      <Composer
        agent={agent}
        onSend={onSend}
        busy={busy}
        onStop={onStop}
        availableSkills={availableSkills}
        sessionSkills={sessionSkills || []}
        sessionMcps={sessionMcps || []}
        sessionLocalTools={sessionLocalTools || []}
        runContext={runContext}
        onRunContextChange={onRunContextChange}
        onToggleSessionSkill={onToggleSessionSkill}
        onToggleSessionMcp={onToggleSessionMcp}
        onToggleSessionLocalTool={onToggleSessionLocalTool}
      />
    </div>
  );
}

window.MidPanel = MidPanel;
