// 对话视图(任务列表 + 选择/渲染消息 + 发送/SSE 流式 + 任务生命周期):
// 任务列表浏览/筛选/滚动加载、selectTask 切换、renderChatMeta、模型下拉、
// renderMessages、live-run 助手、sendMessage/cancel、fetchSse/handleSseEvent、
// 润色/粘贴文件、完成/废弃/删除/导出/清空。共享 state.liveRuns + chat-stream DOM。
// 各入口模块顶层自绑;对外导出 loadTaskList / loadModels / selectTask
// (供 main enterApp、embed、files、newtask)。
import { state, LS_TASK_FILTERS_COLLAPSED } from "./state.js";
import { $, showMenu } from "./dom.js";
import { api } from "./api.js";
import { escapeHtml, fmtTime, fmtTokens, fmtTimeAgo, taskUsageTooltip, formatTaskUsage, formatContextStats, formatUsageStats, ctxRingHtml } from "./format.js";
import { renderMd, highlightIn } from "./markdown.js";
import { mqPhone, setMobileView } from "./layout.js";
import { dialogConfirm, dialogPrompt, message } from "./dialog.js";

// 微信 logo(simple-icons WeChat path),用于渠道任务徽章;fill 走 currentColor(徽章里为白)
const WECHAT_ICON = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.5-6.446 1.45-1.595 3.711-2.55 6.286-2.55.165 0 .33.01.495.027C18.486 4.916 13.929 2.188 8.691 2.188zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.121 2.361-.343a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088a8.067 8.067 0 0 0-.346-.034zm-2.71 3.711c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z"/></svg>`;
// 渠道镜像 task(后端 _run_channel_conversation 建):'wechat'=个人微信 ClawBot,'wecom'=企业微信。
// 两者在 web 端都是只读镜像(唯一交互入口在对应 App,web 发的消息推不回去)—— 列表打绿徽章 +
// 绿边、对话框 readonly。单一真相源:徽章文案 / 锁定提示 / 发送拦截全读这张表。logo 复用微信系图标。
const CHANNEL_BADGE = {
  wechat: { label: "微信", title: "微信 ClawBot 渠道" },
  wecom: { label: "企业微信", title: "企业微信渠道" },
};
function channelCfg(ch) { return CHANNEL_BADGE[ch] || null; }
import { logout } from "./auth.js";
import { openWechatModal } from "./wechat.js";
import { openFilePreview, openPasteFilePreview, closePreviewIfShowing } from "./preview.js";
import { loadFiles, scheduleFilesRefresh, uploadFiles, formatUploadProgress } from "./files.js";
import { toolActivityLabel, _workingDirName, extractMediaBanner, extractArtifactRels, renderArtifactBarHtml, upgradeMediaArtifacts, ARTIFACT_PRODUCING_TOOLS, _flushMediaArtifactCache } from "./media.js";
import { applyProgressAction, cloneProgressSteps, progressActionsFromToolCalls } from "./progress.js";
import { refreshProcs, decorateBgprocCard, hasRunningProc, killTaskProcs } from "./procs.js";

export async function loadModels() {
  try {
    const data = await api("GET", "/v1/models");
    state.models = data.models || [];
  } catch (e) {
    state.models = [];  // 静默兜底:无模型清单时下拉不显示,不挡正常流程
  }
  try {
    const data = await api("GET", "/v1/image_models");
    state.imageModels = data.models || [];
    // 默认锁定第一个(=agent_builder fallback);用户后续切换就会更新
    if (!state.imageModel) {
      const def = state.imageModels.find(m => m.is_default) || state.imageModels[0];
      state.imageModel = def ? def.variant : "";
    }
  } catch (e) {
    state.imageModels = [];
    state.imageModel = "";
  }
  try {
    const data = await api("GET", "/v1/video_models");
    state.videoModels = data.models || [];
    if (!state.videoModel) {
      const def = state.videoModels.find(m => m.is_default) || state.videoModels[0];
      state.videoModel = def ? def.variant : "";
    }
  } catch (e) {
    state.videoModels = [];
    state.videoModel = "";
  }
  // embed + task_id 场景下 selectTask 可能在 loadModels 完成前就跑完 renderChatMeta,
  // 此时 models 为空 → 模型下拉不渲染。loadModels 收尾时如果已选中 task,补一次 chat-meta 重渲。
  if (state.taskMeta) renderChatMeta();
}

// loadTaskList:默认 reset(filters/refresh/写操作后),append=true 由 sentinel observer 触发
// 并发模型:append 受 taskLoading 互斥(避免观察器重复触发);reset 永远抢占,用 seq 丢弃过期响应
let _taskLoadSeq = 0;
export async function loadTaskList({ append = false } = {}) {
  if (append && (state.taskLoading || !state.taskHasMore)) return;
  const mySeq = ++_taskLoadSeq;
  const nextPage = append ? state.taskPage + 1 : 1;
  const params = new URLSearchParams();
  params.set("page", nextPage);
  params.set("page_size", state.taskPageSize);
  const st = $("filter-status").value;
  if (st) params.set("status", st);
  const q = $("filter-q").value.trim();
  if (q) params.set("q", q);
  const wd = $("filter-wd").value.trim();
  if (wd) params.set("working_dir", wd);
  const ord = $("filter-order").value;
  if (ord && ord !== "-created_at") params.set("ordering", ord);  // 默认值不发送,URL 更干净
  state.taskLoading = true;
  setSentinel(append ? "加载中…" : "");
  try {
    const data = await api("GET", "/v1/tasks?" + params.toString());
    if (mySeq !== _taskLoadSeq) return;  // 已被更新的请求 supersede,丢弃
    state.taskTotal = data.count || 0;
    state.taskPage = data.page || nextPage;
    state.taskPageSize = data.page_size || state.taskPageSize;
    const results = data.results || [];
    if (!append) state.taskLoaded = 0;
    state.taskLoaded += results.length;
    state.taskHasMore = state.taskLoaded < state.taskTotal;
    renderTaskList(results, append);
    renderTaskCount();
    subscribeRunningRows(results);
  } catch (e) {
    if (mySeq !== _taskLoadSeq) return;
    if (e.status === 401) { logout(); return; }
    if (!append) {
      $("task-list").innerHTML = `<div class="empty">加载失败:${escapeHtml(e.message)}</div>`;
      state.taskHasMore = false;
    }
    setSentinel(`加载失败:${e.message}`);
  } finally {
    if (mySeq === _taskLoadSeq) state.taskLoading = false;
  }
}

// 列表带出的 running/cancelling 行,本地未订阅的自动挂 SSE 接管(刷新后重新盯上、
// 别的标签页/渠道启动的 run 也能盯):跑完 done/error 走 fetchSse 现有收尾
// (清 liveRuns + 就地清标识 + loadTaskList 重拉),标识全程实时,不需要轮询。
// 上限防浏览器同源连接数被后台流占满(HTTP/1.1 并发约 6,要给选中 task 的
// 订阅和普通 API 留口子);超限的行标识仍显示(服务端快照),只是不自动清。
const MAX_BG_SSE = 4;
function subscribeRunningRows(tasks) {
  for (const t of tasks) {
    if (t.run_status !== "running" && t.run_status !== "cancelling") continue;
    if (getLiveRun(t.task_id)) continue;
    if (state.liveRuns.size >= MAX_BG_SSE) break;
    ensureRunningTaskSubscribed(t.task_id, `/v1/tasks/${t.task_id}/events`, t);
  }
}

function renderTaskCount() {
  $("task-count").textContent = state.taskTotal > 0 ? `共 ${state.taskTotal} 个` : "";
  if (state.taskTotal === 0) setSentinel("");
  else if (!state.taskHasMore) setSentinel(state.taskPage > 1 ? "— 已加载全部 —" : "");
  else setSentinel("");  // 还有更多 → 留空,observer 触发时再填"加载中"
}

function setSentinel(text) {
  $("task-sentinel").textContent = text || "";
}

// 列表行运行态:服务端 run_status 快照 + 本地 liveRuns 叠加(本会话刚发出的 run 比列表快照新)
function taskRunState(t) {
  const live = state.liveRuns.get(t.task_id);
  if (live) return live.cancelling ? "cancelling" : "running";
  return t.run_status || "idle";
}

// 只渲一个带色脉冲圆点(绿=运行中/橙=停止中/红=出错),文案收进 hover title——
// 列表 meta 行本就拥挤,多两个字就挤断行
const RUN_IND_TITLE = {
  running:    "运行中(调用工具 / 回复中)",
  cancelling: "停止中",
  error:      "",  // 动态填 run_error
};

function runIndicatorHtml(t) {
  const rs = taskRunState(t);
  if (!(rs in RUN_IND_TITLE)) return "";  // idle → 不显示
  const title = rs === "error" ? (t.run_error || "上次执行出错") : RUN_IND_TITLE[rs];
  return `<span class="run-ind ${rs}" title="${escapeHtml(title)}"><span class="run-dot"></span></span>`;
}

// run 开始 / 停止请求后就地刷新对应列表行的运行态标识(不重拉列表 — loadTaskList reset
// 会把滚动加载的分页收回第一页);run 结束由 fetchSse 收尾的 loadTaskList() 全量重拉兜底。
// 行不在当前列表窗口(被筛掉 / 未滚动加载到)则跳过,下次重拉自然带出。
function syncTaskRowRunIndicator(tid) {
  const row = document.querySelector(`.task-row[data-tid="${CSS.escape(tid)}"]`);
  if (!row) return;
  // 圆点 host 与渲染时同规则:优先文件夹行,无文件夹行回落底部数据行
  const host = row.querySelector(".wd-line") || row.querySelector(".meta.stats");
  if (!host) return;
  const old = host.querySelector(".run-ind");
  if (old) old.remove();
  const html = runIndicatorHtml((state.tasksById || {})[tid] || { task_id: tid });
  if (!html) return;
  const badge = host.querySelector(".badge");
  if (badge) badge.insertAdjacentHTML("afterend", html);
  else host.insertAdjacentHTML("afterbegin", html);
}

function renderTaskList(tasks, append = false) {
  if (!append) state.tasksById = {};
  for (const t of tasks) state.tasksById[t.task_id] = t;
  if (!append && !tasks.length) {
    $("task-list").innerHTML = `<div class="empty">(暂无任务)</div>`;
    return;
  }
  if (append && !tasks.length) return;  // 末页空 batch,不动 DOM
  // 默认态静默:active 不挂徽章(列表主体都是 active,重复徽章是纯噪音),
  // 终态(completed/abandoned)才着色 + 整行淡化(st-* class),瞬时态交给运行圆点
  const statusLabels = { completed: "已完成", abandoned: "已废弃" };
  const html = tasks.map((t) => {
    const active = state.taskId === t.task_id ? " active" : "";
    // 主行 = 任务名(必填字段);副行 = 工作目录 + description(都按需显示)
    const taskName = t.name || "(未命名)";
    const wdName = t.working_dir ? t.working_dir.split("/").filter(Boolean).pop() : "";
    const desc = t.description || "";
    const statusLabel = statusLabels[t.status] || "";
    // st- 前缀防撞 .task-row.active(选中态)——status 值 "active" 不能直接当 class 用
    const stCls = statusLabel ? ` st-${t.status}` : "";
    const rowTitle = `${taskName}\n${t.task_id}`;  // hover 出全名 + 完整 id(替代 meta 里被去掉的 id8)
    // 状态(终态徽章 + 运行圆点)放文件夹行左侧——行首左上区最先被扫到;
    // 无文件夹行的 task 回落到底部数据行行首(syncTaskRowRunIndicator 同规则找 host)
    const indHtml = `${statusLabel ? `<span class="badge ${t.status}">${statusLabel}</span>` : ""}${runIndicatorHtml(t)}`;
    // 渠道镜像 task(微信 / 企业微信)不进此列表 —— 后端 /v1/tasks 已排除,改由左栏卡片承载(loadChannelCards)
    return `
      <div class="task-row${active}${stCls}" data-tid="${t.task_id}" title="${escapeHtml(rowTitle)}" style="display:flex;align-items:flex-start;gap:6px;">
        <div style="flex:1;min-width:0;">
          <div class="desc">${escapeHtml(taskName)}</div>
          ${wdName ? `<div class="meta muted wd-line" title="${escapeHtml(t.working_dir || "")}" style="display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${indHtml}📁 ${escapeHtml(wdName)}</div>` : ""}
          ${desc ? `<div class="meta muted" title="${escapeHtml(desc)}" style="display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(desc)}</div>` : ""}
          <div class="meta stats">
            ${wdName ? "" : indHtml}
            ${t.skill ? `<span class="muted" title="${escapeHtml(t.skill)}">${escapeHtml(t.skill)}</span>` : ""}
            <span class="num">${t.n_messages || 0} 条</span>
            <span class="num" title="${escapeHtml(taskUsageTooltip(t))}">${fmtTokens(t.tokens)} tok</span>
            <span class="muted time-ago" title="${escapeHtml(fmtTime(t.updated_at))}">${escapeHtml(fmtTimeAgo(t.updated_at))}</span>
          </div>
        </div>
        <button class="dd-toggle task-menu" data-tid="${t.task_id}" title="任务操作">⋯</button>
      </div>
    `;
  }).join("");
  const listEl = $("task-list");
  let newRows;
  if (append) {
    const tmp = document.createElement("div");
    tmp.innerHTML = html;
    newRows = Array.from(tmp.children);
    newRows.forEach((el) => listEl.appendChild(el));
  } else {
    listEl.innerHTML = html;
    newRows = Array.from(listEl.querySelectorAll(".task-row"));
  }
  newRows.forEach((el) => {
    if (!el.classList || !el.classList.contains("task-row")) return;
    el.onclick = (e) => {
      if (e.target.closest(".dd-toggle")) return;  // 菜单按钮点击不触发选中
      selectTask(el.dataset.tid);
    };
    const btn = el.querySelector(".task-menu");
    if (btn) {
      btn.onclick = (e) => {
        e.stopPropagation();
        const t = state.tasksById[btn.dataset.tid];
        if (!t) return;
        showMenu(btn, taskMenuItems(t));
      };
    }
  });
}

// 渠道镜像卡片(微信 / 企业微信):左栏「新建任务」下方固定入口,收拢所有渠道交互
// (绑定 / 对话 / 管理)。后端 /v1/channel_tasks 返回 { bound: bool, task: <task_dict>|null },
// 前端据此渲染三种卡片:未绑定(点绑定)、已绑定无对话(占位)、已绑定有对话(点进 + ⚙ 管理)。
export async function loadChannelCards() {
  const box = $("channel-cards");
  if (!box) return;
  let data;
  try {
    data = await api("GET", "/v1/channel_tasks");
  } catch (e) {
    if (e.status === 401) { logout(); return; }
    box.innerHTML = "";  // 拉失败不挡主流程,卡片静默隐藏
    return;
  }
  // CHANNEL_BADGE 的键序决定卡片顺序。每渠道都有一张卡片(占位或对话)。
  const cards = Object.keys(CHANNEL_BADGE).map((kind) => ({ kind, info: data && data[kind] }));
  box.innerHTML = cards.map(({ kind, info }) => {
    const cfg = CHANNEL_BADGE[kind];
    const bound = info && info.bound;
    const t = info && info.task;
    let html = "";
    if (!bound) {
      // 未绑定:占位卡片,点打开弹框绑定
      html = `
        <div class="channel-card cc-placeholder" data-kind="${kind}" data-action="bind"
             title="绑定${cfg.label}后在${cfg.label}里直接和 zcbot 对话">
          <span class="cc-icon">${WECHAT_ICON}</span>
          <span class="cc-body">
            <span class="cc-name">绑定${cfg.label}</span>
            <span class="cc-meta">扫码或手填 userid</span>
          </span>
        </div>`;
    } else if (!t) {
      // 已绑定但还没首条消息(无 task):占位卡片,提示发消息后可打开,右侧 ⚙ 管理
      html = `
        <div class="channel-card cc-placeholder" data-kind="${kind}" data-action="manage"
             title="在${cfg.label}发条消息后即可打开对话">
          <span class="cc-icon">${WECHAT_ICON}</span>
          <span class="cc-body">
            <span class="cc-name">${cfg.label}对话</span>
            <span class="cc-meta">发消息后可打开</span>
          </span>
          <span class="cc-action" title="管理${cfg.label}绑定">⚙</span>
        </div>`;
    } else {
      // 已绑定且有对话:正常卡片,点打开,右侧 ⚙ 管理
      const active = state.taskId === t.task_id ? " active" : "";
      const name = t.name || cfg.label + "对话";
      const meta = `${t.n_messages || 0} 条 · ${escapeHtml(fmtTimeAgo(t.updated_at))}`;
      html = `
        <div class="channel-card${active}" data-tid="${t.task_id}" data-kind="${kind}"
             data-action="select" title="${escapeHtml(cfg.title)}">
          <span class="cc-icon">${WECHAT_ICON}</span>
          <span class="cc-body">
            <span class="cc-name">${escapeHtml(name)}</span>
            <span class="cc-meta">${meta}</span>
          </span>
          <span class="cc-action" title="管理${cfg.label}绑定">⚙</span>
        </div>`;
    }
    return html;
  }).join("");
  // 绑定事件:卡片整体点击 → selectTask(有 tid 时);右侧 cc-action 点击 → openWechatModal,阻止冒泡
  box.querySelectorAll(".channel-card").forEach((el) => {
    const action = el.dataset.action;
    if (action === "bind" || action === "manage") {
      el.onclick = () => openWechatModal();
    } else if (action === "select") {
      el.onclick = (e) => {
        if (e.target.closest(".cc-action")) {
          e.stopPropagation();  // ⚙ 点开弹框,不触发 selectTask
          openWechatModal();
        } else {
          selectTask(el.dataset.tid);
        }
      };
    }
  });
}

// selectTask 切换时同步卡片高亮(卡片 task 不在 .task-row 列表里,需单独刷 active 态)
function syncChannelCardActive(tid) {
  document.querySelectorAll("#channel-cards .channel-card").forEach((el) => {
    el.classList.toggle("active", el.dataset.tid === tid);
  });
}

function taskMenuItems(t) {
  const isActive = t.status === "active";
  const hasMsg = (t.n_messages || 0) > 0;
  // run_status 列表行与 taskMeta 都带(_task_dict 统一出);running/cancelling 时禁清空
  const running = taskRunState(t) === "running" || taskRunState(t) === "cancelling";
  return [
    { act: "complete", label: "完成",       cls: "act-complete", disabled: !isActive,
      onclick: () => setTaskStatus(t.task_id, "completed", t.name || "(未命名)") },
    { act: "abandon",  label: "废弃",       cls: "act-abandon",  disabled: !isActive,
      onclick: () => setTaskStatus(t.task_id, "abandoned", t.name || "(未命名)") },
    { act: "rename",   label: "重命名",      cls: "act-rename",
      onclick: () => renameTask(t.task_id, t.name || "") },
    { act: "export",   label: "导出对话",     cls: "act-export",   disabled: !hasMsg,
      onclick: () => exportTask(t.task_id) },
    { act: "clear",    label: "清空对话",     cls: "act-clear",    disabled: !hasMsg || running,
      onclick: () => clearMessages(t.task_id, t.name || "(未命名)", t.n_messages || 0) },
    { act: "delete",   label: "删除",       cls: "act-delete",
      onclick: () => deleteTask(t.task_id, t.name || "(未命名)", t.n_messages || 0) },
  ];
}

// 筛选 / 排序 / 刷新 一律 reset(loadTaskList 默认 append=false);追加由 sentinel observer 触发
$("filter-status").onchange = () => loadTaskList();
$("filter-order").onchange = () => loadTaskList();
$("filter-wd").onchange = () => loadTaskList();   // select 选完立即筛
$("btn-refresh-tasks").onclick = () => { loadTaskList(); loadChannelCards(); };

// 筛选区折叠(默认折叠;偏好持久化)。折叠只藏 UI,已选中的筛选条件仍生效。
function applyTaskFiltersCollapsed(collapsed) {
  document.body.classList.toggle("task-filters-collapsed", collapsed);
  $("filter-toggle").textContent = collapsed ? "筛选 ▸" : "筛选 ▾";
}
$("filter-toggle").onclick = () => {
  const next = !document.body.classList.contains("task-filters-collapsed");
  localStorage.setItem(LS_TASK_FILTERS_COLLAPSED, next ? "1" : "0");
  applyTaskFiltersCollapsed(next);
};
// 默认折叠:只有用户显式展开过(存 "0")才展开
applyTaskFiltersCollapsed(localStorage.getItem(LS_TASK_FILTERS_COLLAPSED) !== "0");

// 搜索 q 是 text input → 300ms debounce 避免每字符打 API
let _filterDebounce = null;
$("filter-q").addEventListener("input", () => {
  clearTimeout(_filterDebounce);
  _filterDebounce = setTimeout(() => loadTaskList(), 300);
});

// 滚动加载:只让 task 列表区域滚,顶部标题 / 新建 / 筛选 / 排序固定。
// rootMargin 提前 200px 触发,体感更顺;阈值 0 即可(刚进入即触发,append 期间 taskLoading 自带防抖)
const _taskScrollObserver = new IntersectionObserver((entries) => {
  if (entries[0].isIntersecting && state.taskHasMore && !state.taskLoading) {
    loadTaskList({ append: true });
  }
}, { root: $("task-scroll"), rootMargin: "200px 0px" });
_taskScrollObserver.observe($("task-sentinel"));

// ───── select task ─────
export async function selectTask(tid) {
  if (state.evtSrc) { state.evtSrc.close(); state.evtSrc = null; }
  // 切 task 清掉上个 task 累积的 inline media blob URL — 新 task 的 rel 不同,
  // 旧 URL 留着只占内存。同 task 切回(tid === state.taskId)不算切换,跳过。
  if (state.taskId && state.taskId !== tid) { _flushMediaArtifactCache(); clearAttachTray(); }
  state.taskId = tid;
  document.querySelectorAll(".task-row").forEach((el) => {
    el.classList.toggle("active", el.dataset.tid === tid);
  });
  syncChannelCardActive(tid);  // 渠道卡片 task 不在 .task-row 列表,单独同步高亮
  // 手机视图:选中任务自动切到对话面板(桌面 mqPhone 不命中 → no-op)
  if (mqPhone.matches) setMobileView("mv-mid");
  // 立即清空 + 显示加载占位:切 task 体感瞬时跟手,不等 meta/messages 两个 await
  $("chat-stream").innerHTML = `<div class="empty">加载中…</div>`;
  // 先锁后放:按钮先置禁用加载态,meta/proc 状态到齐后由下方流程放到正确状态
  // (idle/streaming/bgproc)—— 杜绝"刷新/切换瞬间按钮短暂可用"的时序窗口
  setActionMode("loading");
  renderTaskProgressDock([]);
  state.outline = []; renderOutlineRail();  // 切 task 先清旧目录,refreshOutline 拉到再渲
  try {
    // meta / messages / outline 三者无依赖,并发拉省 RTT(切 task 体感更跟手)。
    // loadMessages、refreshOutline 内部读 state.taskId(上方已置),不依赖 meta。
    // 但消息**渲染**依赖 meta(artifact chip 抽取要 working_dir)→ loadMessages 只取数,
    // 渲染延后到 taskMeta 赋值之后;代价是首屏渲染等 meta/messages 中较慢者(meta 很轻)。
    const [meta] = await Promise.all([
      api("GET", "/v1/tasks/" + tid),
      loadMessages({ render: false }),
      refreshOutline(),
      // proc 状态并入预取:后面 renderLiveRunIfVisible 决定按钮态时数据已就位,
      // 消掉"刷新后按钮先短暂可用、锁随轮询迟到"的亚秒窗口
      refreshProcs(),
    ]);
    state.taskMeta = meta;
    renderMessages(state.loadedMessages);  // 内部含 renderPersistedRunTerminal(须在 taskMeta 赋值后)
    renderChatMeta();
    applyChannelComposerLock(meta);
    if (meta.run_status === "running" || meta.run_status === "cancelling") {
      ensureRunningTaskSubscribed(tid, `/v1/tasks/${tid}/events`, meta);
    } else {
      renderLiveRunIfVisible();
    }
    // 文件面板自动跳到该 task 的 working_dir(user_root 下一级子目录),
    // 不强绑定 — 用户可点 crumb 回上层看 user_root 其他目录
    const wdName = meta.working_dir ? meta.working_dir.split("/").filter(Boolean).pop() : "";
    state.filesPath = wdName || "";
    await loadFiles();
    refreshConcurrentWarnings();  // 同 wd 其他 task 活跃软警告 — 后台 fire-and-forget
  } catch (e) {
    if (e.status === 401) { logout(); return; }
    renderTaskProgressDock([]);
    $("chat-stream").innerHTML = `<div class="empty">加载失败:${escapeHtml(e.message)}</div>`;
    setActionMode("idle");  // 加载失败也要把"先锁后放"的锁解开,别把 composer 卡死
  }
}

// 拉同 wd 内除自己外仍 running/cancelling 的 task,渲染软警告 banner。
// 同 wd 多 task 同时跑频率近 0(用户工作流以"同项目对话历史轨迹"为主,不并发),
// 这里只做提示,不挡发送(对应 DESIGN §7.8 / §7.9 "信任 + 软警告 + 承认边界")。
async function refreshConcurrentWarnings() {
  const t = state.taskMeta;
  if (!t || !t.working_dir) { state.concurrentWarnings = []; renderConcurrentWarning(); return; }
  const wdName = t.working_dir.split("/").filter(Boolean).pop();
  if (!wdName) { state.concurrentWarnings = []; renderConcurrentWarning(); return; }
  const params = new URLSearchParams();
  params.set("working_dir", wdName);
  params.set("run_status", "running,cancelling");
  params.set("page_size", "10");
  try {
    const data = await api("GET", "/v1/tasks?" + params.toString());
    const others = (data.results || []).filter(r => r.task_id !== t.task_id);
    state.concurrentWarnings = others;
  } catch (e) {
    // 警告失败不影响主功能,静默
    state.concurrentWarnings = [];
  }
  renderConcurrentWarning();
}

function renderConcurrentWarning() {
  const el = $("wd-concurrent-warn");
  const others = state.concurrentWarnings;
  if (!others || others.length === 0) { el.style.display = "none"; el.innerHTML = ""; return; }
  const head = others[0];
  const t = state.taskMeta;
  const wdName = t && t.working_dir ? t.working_dir.split("/").filter(Boolean).pop() : "";
  const more = others.length > 1 ? ` 等 ${others.length} 个` : "";
  el.innerHTML = `⚠ 项目 "${escapeHtml(wdName)}" 内 task <span class="tname">${escapeHtml(head.name || "(未命名)")}</span> 正在 <span class="rs">${escapeHtml(head.run_status)}</span>${more} — 并发写同名中间产物可能互覆,建议等它结束再发`;
  el.style.display = "block";
}

// 压缩指示环就地重绘(run 中 llm_start / context_fold 事件驱动,不整行重渲 meta —
// 避免打断模型下拉等交互态)。taskMeta 上的 context_* 字段是唯一数据源。
function refreshCtxRing() {
  const el = $("ctx-ring");
  if (el && state.taskMeta) el.outerHTML = ctxRingHtml(state.taskMeta);
}

function renderChatMeta() {
  const t = state.taskMeta;
  if (!t) { $("chat-meta").innerHTML = `<span class="muted">(未选中任务)</span>`; return; }
  const wdName = t.working_dir ? t.working_dir.split("/").filter(Boolean).pop() : "";
  const taskName = t.name || "(未命名)";
  // 同列表规则:active 静默,终态才挂徽章(它同时解释"输入框为什么消失了")
  const statusLabel = { completed: "已完成", abandoned: "已废弃" }[t.status] || "";
  // wdName 与 taskName 相同时(留空 fallback,多数场景)不重复显示 📁;
  // 不同时(用户显式指定共享目录 / 改了 name)才挂 📁,提示"项目归属"
  const wdBadge = (wdName && wdName !== taskName)
    ? `<span class="muted" title="${escapeHtml(t.working_dir)}">📁 ${escapeHtml(wdName)}</span>`
    : "";
  $("chat-meta").innerHTML = `
    <span style="font-weight:600;" title="${escapeHtml(taskName)}">${escapeHtml(taskName)}</span>
    ${statusLabel ? `<span class="badge ${t.status}">${statusLabel}</span>` : ""}
    ${wdBadge}
    ${t.skill ? `<span class="muted">${escapeHtml(t.skill)}</span>` : ""}
    <span class="tid">${t.task_id.slice(0, 8)}</span>
    ${formatTaskUsage(t)}
    ${ctxRingHtml(t)}
    ${t.description ? `<span class="muted desc">${escapeHtml(t.description)}</span>` : ""}
    <span class="spacer"></span>
    ${renderModelDropdown(t)}
    ${renderMediaModelTrigger()}
  `;
  const sel = $("chat-model-sel");
  if (sel) sel.onchange = onChangeModel;
  // 生图/生视频 收进 ⚙ 弹层(低频),点开时再渲染 select 并接 onChange
  const mmBtn = $("media-model-btn");
  if (mmBtn) mmBtn.onclick = (e) => { e.stopPropagation(); openMediaModelPop(mmBtn); };
  const active = t.status === "active";
  $("chat-form").style.display = active ? "flex" : "none";
  syncOptimizeBtn();
  $("btn-done").disabled = !active;
  // ⋯ 菜单:选中即可用;各项 enable/disable(完成/废弃按 status、清空按 run_status+n_messages)
  // 全在 taskMenuItems 内部判定,这里只管整体可用性。
  $("btn-task-menu").disabled = false;
}

function renderModelDropdown(t) {
  // 模型清单未加载好(或为空)时不渲染下拉,但 task 仍可正常用(后端走 task.model_profile)
  if (!state.models || state.models.length === 0) return "";
  const cur = t.model_profile || "";
  let opts = state.models.map(m =>
    `<option value="${escapeHtml(m.profile)}" ${m.profile === cur ? "selected" : ""}>${escapeHtml(m.display_name)}</option>`
  ).join("");
  // 当前模型不在该用户档位清单里(skill 定向模型,如 ppt → glm.pro52)→ 补一项,
  // 下拉如实显示;用户仍可切回档内模型。
  if (cur && !state.models.some(m => m.profile === cur)) {
    opts += `<option value="${escapeHtml(cur)}" selected>${escapeHtml(cur)}</option>`;
  }
  return `<span class="muted small mdl-wrap" style="display:inline-flex;align-items:center;gap:4px;"><span class="mdl-text">模型</span><span class="mdl-icon" aria-hidden="true">💬</span><select id="chat-model-sel" class="small" style="width:auto;padding:1px 4px;font-size:12px;" title="切换 task 模型(下条消息生效)">${opts}</select></span>`;
}

// 生图/生视频 是低频情景操作 → 不在 meta 行常驻,收进一个 ⚙ 弹层。
// imageModels/videoModels 均为空(yaml 无 image/video variant)时连 ⚙ 都不画。
function renderMediaModelTrigger() {
  const hasImg = state.imageModels && state.imageModels.length > 0;
  const hasVid = state.videoModels && state.videoModels.length > 0;
  if (!hasImg && !hasVid) return "";
  return `<button id="media-model-btn" class="small" title="生图 / 生视频 模型选择">⚙ 媒体</button>`;
}

// 弹层里一行 = 标签(icon + 文字) + select。沿用原 onChange,选中值仍只进 state.* 随下条消息发。
function mediaSelectRow(icon, label, id, list, cur, title) {
  const opts = list.map(m =>
    `<option value="${escapeHtml(m.variant)}" ${m.variant === cur ? "selected" : ""}>${escapeHtml(m.display_name)}</option>`
  ).join("");
  return `<div class="mm-row"><span class="mm-label">${icon} ${label}</span>`
    + `<select id="${id}" title="${title}">${opts}</select></div>`;
}

function openMediaModelPop(btn) {
  const pop = $("media-model-pop");
  let html = "";
  if (state.imageModels && state.imageModels.length)
    html += mediaSelectRow("🖼", "生图", "chat-image-model-sel", state.imageModels, state.imageModel || "", "下一条消息触发生图时使用的模型(本地选择,不入库)");
  if (state.videoModels && state.videoModels.length)
    html += mediaSelectRow("🎬", "生视频", "chat-video-model-sel", state.videoModels, state.videoModel || "", "下一条消息触发生视频时使用的模型(本地选择,不入库)");
  pop.innerHTML = html;
  const imgSel = $("chat-image-model-sel"); if (imgSel) imgSel.onchange = onChangeImageModel;
  const vidSel = $("chat-video-model-sel"); if (vidSel) vidSel.onchange = onChangeVideoModel;
  // 定位:右上角对齐触发按钮,向下展开;下方空间不足则向上(同 showMenu 思路)
  const rect = btn.getBoundingClientRect();
  pop.style.visibility = "hidden";
  pop.classList.add("show");
  const ph = pop.offsetHeight || 100;
  const pw = pop.offsetWidth || 220;
  // 同 showMenu:触发按钮靠左时右对齐会冲出左边缘 → 改左对齐并钳在视口内
  if (rect.right - pw < 4) {
    pop.style.left = Math.max(4, Math.min(rect.left, window.innerWidth - pw - 4)) + "px";
    pop.style.right = "auto";
  } else {
    pop.style.right = Math.max(4, window.innerWidth - rect.right) + "px";
    pop.style.left = "auto";
  }
  pop.style.top = (rect.bottom + ph + 8 > window.innerHeight)
    ? Math.max(4, rect.top - ph - 4) + "px"
    : (rect.bottom + 4) + "px";
  pop.style.visibility = "";
}
function closeMediaModelPop() { $("media-model-pop").classList.remove("show"); }
// 点弹层外 / resize / 滚动 → 关(选 select 选项的点击落在弹层内,不会误关)
document.addEventListener("click", (e) => {
  if (e.target.closest("#media-model-btn") || e.target.closest("#media-model-pop")) return;
  closeMediaModelPop();
}, true);
window.addEventListener("resize", closeMediaModelPop);
document.addEventListener("scroll", closeMediaModelPop, true);

function onChangeImageModel(ev) {
  // 纯前端 state,不 PATCH;选中值随下一次 POST /v1/tasks/{id}/messages 的 image_model 字段一起发
  state.imageModel = ev.target.value || "";
  $("chat-hint").textContent = `生图模型 → ${ev.target.options[ev.target.selectedIndex].text}`;
}

function onChangeVideoModel(ev) {
  state.videoModel = ev.target.value || "";
  $("chat-hint").textContent = `生视频模型 → ${ev.target.options[ev.target.selectedIndex].text}`;
}

async function onChangeModel(ev) {
  const sel = ev.target;
  const newProfile = sel.value;
  const t = state.taskMeta;
  if (!t || !newProfile || newProfile === t.model_profile) return;
  const oldProfile = t.model_profile || "";
  try {
    const updated = await api("PATCH", `/v1/tasks/${t.task_id}`, { model_profile: newProfile });
    // PATCH 返回不含 context_* 字段(仅详情端点算),merge 保住指示环数据;
    // 换模型后容量略陈旧,下一次 llm_start / 重开任务自动校正
    state.taskMeta = { ...t, ...updated };
    const running = updated.run_status === "running" || updated.run_status === "cancelling";
    $("chat-hint").textContent = running
      ? `已切到 ${newProfile} · 当前 run 跑完后生效`
      : `已切到 ${newProfile}`;
  } catch (e) {
    sel.value = oldProfile;  // PATCH 失败 UI 回滚
    $("chat-hint").textContent = `切换失败:${e.message}`;
  }
}

// 切 task 默认只拉最近一批(尾部窗口);更早的靠向上滚动按需补。
// 30:首屏只需铺满一两屏可见消息,降低传输 + markdown/highlight 同步渲染量,
// 切换更跟手;system/task_progress 等被 render 跳过的行也算在这窗口里,留了余量。
//
// 分页单位仍是消息条数(业界通行:Discord/OpenAI 均按条 + 每页上限,轮次大小无上界
// 不能当传输单位),但向上翻页/跳转的窗口边界尽量对齐到 outline 的 user 轮次起点:
// idx 连续递增(Session.append 递增 / append_channel_message max+1,无单条删除),
// 轮次消息数 = 相邻 outline 边界差,前端即可精确算区间,后端 API 不用动。
const MSG_PAGE = 30;
// 单次拉取硬上限(后端对 limit 无 clamp,靠前端自律)。超大单轮(工具风暴一轮几百条)
// 退化为按条截断 —— 窗口顶落在轮中,后续向上加载会继续朝该轮起点推进,逐步自愈对齐。
const MSG_FETCH_MAX = 100;

// 向上翻页的对齐条数:从当前窗口顶(firstIdx)往前按整轮累加,攒够 MSG_PAGE 条为止,
// 使新窗口顶正好是某轮的 user 消息(不把 assistant 工具调用序列切成孤儿)。
// outline 未加载 / firstIdx 已在首轮之前 → 退回固定 MSG_PAGE(行为同旧版)。
function alignedEarlierLimit(firstIdx) {
  const bounds = (state.outline || []).map((it) => it.idx).filter((b) => b < firstIdx);
  if (!bounds.length) return MSG_PAGE;
  let j = bounds.length - 1;
  while (j > 0 && firstIdx - bounds[j] < MSG_PAGE) j--;
  return Math.min(firstIdx - bounds[j], MSG_FETCH_MAX);
}

async function loadMessages({ render = true } = {}) {
  const data = await api("GET", `/v1/tasks/${state.taskId}/messages?limit=${MSG_PAGE}`);
  state.loadedMessages = data.messages || [];
  state.msgHasMore = !!data.has_more;
  state.msgHasMoreNewer = !!data.has_more_after;  // 尾部窗口通常为 false
  state.msgLoadingEarlier = false;
  state.msgLoadingNewer = false;
  // render=false:selectTask 首屏走延后渲染 —— renderMessages 里 artifact chip 抽取
  // 依赖 state.taskMeta.working_dir,必须等 meta 赋值后再渲,否则 wd 是旧 task 的/null,
  // 历史消息里的图片预览和 chip 整体丢失(0.12.16 并发化引入的回归)。
  if (render) renderMessages(state.loadedMessages);
}

// 向上加载更早一批:取当前已加载窗口最早 idx 之前、按整轮对齐的一批,prepend 进数组后
// 整窗重渲染(renderMessages 仍是对 loadedMessages 的纯函数,时序累积逻辑无需改),
// 重渲后把滚动位置锚回原处,视口不跳。
async function loadEarlierMessages() {
  if (state.msgLoadingEarlier || !state.msgHasMore) return;
  const msgs = state.loadedMessages;
  if (!msgs.length) return;
  const tid = state.taskId;
  const firstIdx = msgs[0].idx;
  state.msgLoadingEarlier = true;
  const wrap = $("chat-stream");
  const prevH = wrap.scrollHeight, prevTop = wrap.scrollTop;
  try {
    const limit = alignedEarlierLimit(firstIdx);
    const data = await api(
      "GET", `/v1/tasks/${tid}/messages?limit=${limit}&before_idx=${firstIdx}`,
    );
    if (state.taskId !== tid) return;  // 加载途中切走了 task,丢弃结果
    const earlier = data.messages || [];
    if (earlier.length) state.loadedMessages = earlier.concat(state.loadedMessages);
    state.msgHasMore = !!data.has_more;
    state.msgLoadingEarlier = false;
    renderMessages(state.loadedMessages);
    // 锚回:新增内容都在上方,保持原先可见的首条仍在原位
    wrap.scrollTop = prevTop + (wrap.scrollHeight - prevH);
  } catch (e) {
    state.msgLoadingEarlier = false;
    if (e.status === 401) logout();
  }
}

// 向下加载更新一批:从目录跳到旧消息后,窗口下方还有未加载的新消息。取当前窗口
// 最新 idx 之后的 MSG_PAGE 条,append 进数组重渲,滚动位置锚回原处(新增都在下方)。
async function loadNewerMessages() {
  if (state.msgLoadingNewer || !state.msgHasMoreNewer) return;
  const msgs = state.loadedMessages;
  if (!msgs.length) return;
  const tid = state.taskId;
  const lastIdx = msgs[msgs.length - 1].idx;
  state.msgLoadingNewer = true;
  const wrap = $("chat-stream");
  const prevTop = wrap.scrollTop;
  try {
    const data = await api(
      "GET", `/v1/tasks/${tid}/messages?limit=${MSG_PAGE}&after_idx=${lastIdx}`,
    );
    if (state.taskId !== tid) return;
    const newer = data.messages || [];
    if (newer.length) state.loadedMessages = state.loadedMessages.concat(newer);
    state.msgHasMoreNewer = !!data.has_more_after;
    state.msgLoadingNewer = false;
    renderMessages(state.loadedMessages);
    wrap.scrollTop = prevTop;  // 新增在下方,保持原视口不跳
  } catch (e) {
    state.msgLoadingNewer = false;
    if (e.status === 401) logout();
  }
}

// 跳到任意一轮(目录点圆点):已加载 → scrollIntoView;未加载 → 从该轮起点精确拉整轮
// (after_idx = 起点-1,limit = 相邻边界差;短轮补到 MSG_PAGE 铺满一屏,超大轮按
// MSG_FETCH_MAX 截断)。窗口首条即目标 user 消息,顶部对齐后正好带全该轮下文,
// 替代旧的 before_idx=idx+11 居中启发式(轮次长时拉不全、短时混入无关上文)。
// idx 不在 outline 里(理论不发生)→ 退回旧启发式窗口。
async function loadMessagesAround(idx) {
  const tid = state.taskId;
  const items = state.outline || [];
  const pos = items.findIndex((it) => it.idx === idx);
  let query;
  if (pos >= 0) {
    const next = items[pos + 1];
    const span = next ? next.idx - idx : MSG_PAGE;  // 末轮长度未知,按 MSG_PAGE 拉
    const limit = Math.min(Math.max(span, MSG_PAGE), MSG_FETCH_MAX);
    query = `limit=${limit}&after_idx=${idx - 1}`;
  } else {
    query = `limit=${MSG_PAGE}&before_idx=${idx + 11}`;
  }
  const data = await api(
    "GET", `/v1/tasks/${tid}/messages?${query}`,
  );
  if (state.taskId !== tid) return false;
  state.loadedMessages = data.messages || [];
  state.msgHasMore = !!data.has_more;
  state.msgHasMoreNewer = !!data.has_more_after;
  state.msgLoadingEarlier = false;
  state.msgLoadingNewer = false;
  renderMessages(state.loadedMessages);
  return true;
}

async function jumpToMessage(idx) {
  const wrap = $("chat-stream");
  let card = wrap.querySelector(`.msg[data-idx="${idx}"]`);
  if (!card) {
    let ok = false;
    try { ok = await loadMessagesAround(idx); }
    catch (e) { if (e.status === 401) { logout(); return; } }
    if (!ok) return;
    card = wrap.querySelector(`.msg[data-idx="${idx}"]`);
  }
  if (!card) return;
  // 顶部对齐(非居中):第一轮上方无内容无法居中、会被钉到顶端,而 updateActiveOutlineDot
  // 按「顶线」判活跃轮 —— 两套锚点必须一致,否则贴顶时活跃圆点会越界到下一轮。
  // .msg 的 scroll-margin-top 给卡片留一点上方呼吸空间。
  setActiveOutlineIdx(idx);
  lockOutlineDuringJump();  // 锁住活跃圆点:平滑滚动途中的 scroll 事件不得把活跃态抢到途经轮次
  card.scrollIntoView({ behavior: "smooth", block: "start" });
  card.classList.add("msg-jump-flash");
  setTimeout(() => card.classList.remove("msg-jump-flash"), 1200);
}

// 顶/底 sentinel 进视口即自动补更早 / 更新 —— 复用 task list 的同款范式。
// root 是 chat-stream 滚动容器;每次 renderMessages 重建 DOM 后重新 observe 新 sentinel。
const _msgScrollObserver = new IntersectionObserver((entries) => {
  for (const en of entries) {
    if (!en.isIntersecting) continue;
    if (en.target.classList.contains("msg-top-sentinel")) {
      if (state.msgHasMore && !state.msgLoadingEarlier) loadEarlierMessages();
    } else if (en.target.classList.contains("msg-bot-sentinel")) {
      if (state.msgHasMoreNewer && !state.msgLoadingNewer) loadNewerMessages();
    }
  }
}, { root: $("chat-stream"), rootMargin: "150px 0px" });

// ───── 消息目录(右侧悬浮圆点轨道)─────
// 切 task / run 收尾后拉全部 user 轮次;点圆点 jumpToMessage 定位;滚动时高亮当前轮。
async function refreshOutline() {
  const tid = state.taskId;
  if (!tid) { state.outline = []; renderOutlineRail(); return; }
  try {
    const data = await api("GET", `/v1/tasks/${tid}/outline`);
    if (state.taskId !== tid) return;
    state.outline = data.items || [];
  } catch (e) {
    if (e.status === 401) { logout(); return; }
    state.outline = [];
  }
  renderOutlineRail();
}

function renderOutlineRail() {
  const rail = $("msg-outline-rail");
  if (!rail) return;  // embed 等精简页无此元素 → no-op
  const items = state.outline || [];
  if (items.length < 2) {  // 0/1 轮没必要显示目录
    rail.style.display = "none";
    rail.innerHTML = "";
    return;
  }
  rail.style.display = "";
  rail.innerHTML = items.map((it, i) => {
    const label = it.snippet || `第 ${i + 1} 轮`;
    return `<button type="button" class="ol-dot" data-idx="${it.idx}" title="${escapeHtml(label)}">`
      + `<span class="ol-num">${i + 1}</span>`
      + `<span class="ol-label">${escapeHtml(label)}</span></button>`;
  }).join("");
  updateActiveOutlineDot();
}

function setActiveOutlineIdx(idx) {
  const rail = $("msg-outline-rail");
  if (!rail) return;
  rail.querySelectorAll(".ol-dot").forEach((d) => {
    d.classList.toggle("active", Number(d.dataset.idx) === Number(idx));
  });
}

// 点圆点跳转期间锁定活跃态:平滑滚动会连发 scroll 事件,若不锁,updateActiveOutlineDot
// 会按动画途中的位置反复改写,把刚点中的圆点抢成途经轮次(表现:点 #1 不变红 / 跳到 #2)。
let _outlineJumpLock = false;
let _outlineJumpTimer = 0;
function lockOutlineDuringJump() {
  _outlineJumpLock = true;
  clearTimeout(_outlineJumpTimer);
  // 平滑滚动一般 <500ms;700ms 兜底解锁后按落点重算一次(触底轮由下面 atBottom 分支兜)
  _outlineJumpTimer = setTimeout(() => {
    _outlineJumpLock = false;
    updateActiveOutlineDot();
  }, 700);
}

// 视口顶线以上的最后一个已加载 user 卡 = 当前轮,高亮对应圆点
function updateActiveOutlineDot() {
  const rail = $("msg-outline-rail");
  if (!rail || rail.style.display === "none") return;
  if (_outlineJumpLock) return;  // 显式跳转动画期间不抢
  const wrap = $("chat-stream");
  const items = state.outline || [];

  // 触底兜底:最后几轮永远顶不到顶线(容器先到底),按原逻辑会一直停在倒数第二个
  // (表现:点最后一个 / 滚到底时倒数第二个变红)。滚到容器底且无更新内容可加载时,
  // 直接判最后一个已加载轮为当前。
  if (!state.msgHasMoreNewer
      && wrap.scrollTop + wrap.clientHeight >= wrap.scrollHeight - 2) {
    for (let i = items.length - 1; i >= 0; i--) {
      if (wrap.querySelector(`.msg[data-idx="${items[i].idx}"]`)) {
        setActiveOutlineIdx(items[i].idx);
        return;
      }
    }
  }

  const top = wrap.getBoundingClientRect().top;
  let activeIdx = null;
  for (const it of items) {
    const card = wrap.querySelector(`.msg[data-idx="${it.idx}"]`);
    if (!card) continue;
    // 容差与 .msg 的 scroll-margin-top(16px)对齐:贴顶的短第一轮判到自己,不越界
    // 到下一轮(80px 太宽:短轮次时下一轮卡片顶也落进带内 → 误高亮第二个圆点)。
    if (card.getBoundingClientRect().top - top <= 24) activeIdx = it.idx;
    else break;  // outline 升序,首个落在视口下方的之后都更靠下
  }
  if (activeIdx != null) setActiveOutlineIdx(activeIdx);
}

let _outlineRaf = 0;
$("chat-stream").addEventListener("scroll", () => {
  if (_outlineRaf) return;
  _outlineRaf = requestAnimationFrame(() => { _outlineRaf = 0; updateActiveOutlineDot(); });
});
// embed 等精简页无 outline-rail 元素 → 跳过绑定(renderOutlineRail 已 null-safe)
const _outlineRailEl = $("msg-outline-rail");
if (_outlineRailEl) {
  _outlineRailEl.addEventListener("click", (e) => {
    const dot = e.target.closest(".ol-dot");
    if (dot) jumpToMessage(Number(dot.dataset.idx));
  });
}

function getLiveRun(taskId) {
  return taskId ? state.liveRuns.get(taskId) : null;
}

function isCurrentTaskStreaming() {
  return !!getLiveRun(state.taskId);
}

// 直播卡片内文字按「轮次」分段:每段一个 .body,工具调用会关闭当前段,之后的新文字
// 在卡片底部另起一段 —— 使流式文字与工具卡按时序穿插、最新文字始终贴在底部可见。
// 历史渲染天然按消息分段,直播这样分段后两态结构一致,run 结束 reload 无跳变。
function ensureTextSeg(run) {
  if (run.curSeg) return run.curSeg;
  const el = document.createElement("div");
  el.className = "body streaming";
  run.card.appendChild(el);
  run.curSeg = { el, acc: "", pending: false };
  return run.curSeg;
}

// 关闭当前文字段:空占位段(还没吐字就来了工具)直接移除,避免留下「思考中」孤块;
// 有内容的段落定稿(去光标 + 代码高亮),之后的新文字会另起新段。
function closeTextSeg(run) {
  const seg = run.curSeg;
  if (!seg) return;
  if (!seg.acc) {
    seg.el.remove();
  } else {
    seg.el.classList.remove("streaming");
    highlightIn(seg.el);
  }
  run.curSeg = null;
}

// reasoning(思考过程)段:thinking 模型的推理 delta 流进灰色可折叠卡,首个正文
// delta / 工具调用到达时定稿折叠。插在当前空占位段之前,保持「思考过程 → 正文」
// 时序;与历史渲染同款结构(details.reasoning),run 结束 reload 无跳变。
function ensureReasonSeg(run) {
  if (run.curReason) return run.curReason;
  const det = document.createElement("details");
  det.className = "reasoning streaming";
  det.open = true;
  det.innerHTML = `<summary>思考过程</summary><div class="reason-body"></div>`;
  const anchor = run.curSeg && !run.curSeg.acc ? run.curSeg.el : null;
  if (anchor) run.card.insertBefore(det, anchor);
  else run.card.appendChild(det);
  run.curReason = { el: det, body: det.querySelector(".reason-body"), acc: "", pending: false };
  return run.curReason;
}

function closeReasonSeg(run) {
  const rs = run.curReason;
  if (!rs) return;
  if (!rs.acc) {
    rs.el.remove();
  } else {
    rs.el.classList.remove("streaming");
    rs.el.open = false;  // 正文开始后折叠,点开可回看
  }
  run.curReason = null;
}

// ───── run 活跃状态指示(阶段 + 已耗时) ─────
// 长耗时阶段(TTFT / 深度推理 / 工具执行)原本页面全静止,和卡死无法区分。
// 每秒把「阶段 · Ns」写进空占位段的 data-status(CSS :empty::before 渲染),
// 工具卡运行中在 summary 里跳秒 —— 秒数在走本身就是最强的"没卡住"信号。
let _statusTicker = null;

function setRunPhase(run, phase) {
  run.phase = phase;
  run.phaseSince = Date.now();
  tickRunStatus(run);
  if (!_statusTicker) {
    _statusTicker = setInterval(() => {
      if (!state.liveRuns.size) { clearInterval(_statusTicker); _statusTicker = null; return; }
      state.liveRuns.forEach(tickRunStatus);
    }, 1000);
  }
}

function tickRunStatus(run) {
  const secs = Math.floor((Date.now() - (run.phaseSince || Date.now())) / 1000);
  const suffix = secs >= 2 ? ` · ${secs}s` : "";
  if (run.curSeg && !run.curSeg.acc && run.curSeg.el) {
    const label = run.phase === "reasoning" ? "深度思考中" : "思考中";
    run.curSeg.el.dataset.status = label + suffix;
  }
  if (run.toolElapsedEl && suffix) run.toolElapsedEl.textContent = suffix;
}

// 工具执行收尾:去 spinner,elapsed 定格(留着当执行耗时看)
function finishRunningTool(run) {
  if (run.runningToolEl) { run.runningToolEl.classList.remove("running"); run.runningToolEl = null; }
  run.toolElapsedEl = null;
}

function createLiveAssistantCard(run) {
  const card = document.createElement("div");
  card.className = "msg assistant live-run";
  card.innerHTML = `<div class="role">助手</div>`;
  run.card = card;
  run.curSeg = null;
  if (run.acc) {
    // 重连:已累积文字作为初始(仍打开的)文字段渲染,后续事件在其后穿插
    const el = document.createElement("div");
    el.className = "body streaming";
    el.innerHTML = renderMd(run.acc);
    card.appendChild(el);
    run.curSeg = { el, acc: run.acc, pending: false };
  } else {
    ensureTextSeg(run);  // 空占位段:首字到达前显示「思考中」
  }
  // 刷新/重连接管:phaseSince 随页面内存丢了,阶段未知先按"思考中"起跳
  // (计时从重连时刻起,非真实累计);下一个 llm_start/tool_call 事件会校正。
  // 不跳秒的静态"思考中"恰是用户刷新的诱因,重连后必须立刻恢复活跃信号。
  if (!run.phaseSince) setRunPhase(run, run.phase || "llm");
  return card;
}

function renderLiveRunIfVisible() {
  const run = getLiveRun(state.taskId);
  if (!run) {
    // run 收尾/切 task 后:本 task 还有 running 后台进程 → 保持对话锁定不回 idle
    if (hasRunningProc(state.taskId)) {
      setActionMode("bgproc");
      if (!_bgprocLocked) {
        _bgprocLocked = true;
        $("chat-hint").textContent = "后台进程运行中,完成后可继续对话…";
      }
    } else {
      _bgprocLocked = false;
      setActionMode("idle");
    }
    return;
  }
  const wrap = $("chat-stream");
  // card 已持有全部文字段/工具卡 DOM(切走再切回只需重新挂载,不重渲);
  // 新建的重连 card 由 createLiveAssistantCard 自行渲染已累积文字。
  const card = run.card || createLiveAssistantCard(run);
  renderTaskProgressDock(run.progressSteps || []);
  if (card.parentElement !== wrap) wrap.appendChild(card);
  wrap.scrollTop = wrap.scrollHeight;
  setActionMode(run.cancelling ? "cancelling" : "streaming");
  $("chat-hint").textContent = run.cancelling ? "停止中…" : "接收中…";
}

// seed = 该 task 的 API dict(taskMeta 或列表行),取 run_status/working_dir。
// 之前从全局 state.taskMeta 读 —— 只对"订阅选中 task"成立;现在列表也会给后台
// running task 挂订阅,workingDir 必须跟着各自 task 走(媒体产物 rel 解析用它)。
function ensureRunningTaskSubscribed(taskId, url, seed = {}) {
  if (!taskId || getLiveRun(taskId)) return;
  const run = {
    taskId,
    url,
    acc: "",
    seenRels: new Set(),
    terminal: false,
    card: null,
    curSeg: null,
    cancelling: seed.run_status === "cancelling",
    workingDir: seed.working_dir || "",
    progressSteps: cloneProgressSteps(state.taskProgressByTask.get(taskId)),
  };
  state.liveRuns.set(taskId, run);
  state.streaming = true;
  syncTaskRowRunIndicator(taskId);
  // 只有订阅的是当前选中 task 才挂直播卡(selectTask 路径);后台行订阅不碰
  // 对话区(renderLiveRunIfVisible 会重挂卡 + 强制滚底,误伤正看着的对话)
  if (taskId === state.taskId) renderLiveRunIfVisible();
  streamSse(url, run);
}

function setRunHint(run, text) {
  if (state.taskId === run.taskId) $("chat-hint").textContent = text;
}

// 进度只在对话区顶部的单一 dock 里渲染(codex 式钉顶面板),不再内联进每条消息卡。
// 进行中:展开实时显示 pending/in_progress/completed;全部完成:折叠成一行摘要,点开看清单。
function renderTaskProgressDock(steps) {
  const dock = $("task-progress-dock");
  if (!dock) return;
  if (!Array.isArray(steps) || !steps.length) {
    dock.innerHTML = "";
    dock.classList.remove("show");
    return;
  }
  const total = steps.length;
  const done = steps.filter(s => s.status === "completed").length;
  const allDone = done === total;
  const mark = (status) => status === "completed" ? "✓" : (status === "in_progress" ? "…" : "");
  const rows = steps.map((s) => `
    <div class="tp-step ${escapeHtml(s.status)}">
      <span class="tp-mark">${escapeHtml(mark(s.status))}</span>
      <span class="tp-text">${escapeHtml(s.title)}</span>
    </div>
  `).join("");
  const summary = allDone
    ? `<summary class="tp-summary tp-done">✓ 全部完成 · ${done}/${total} 步</summary>`
    : `<summary class="tp-summary">进度 · ${done}/${total} 步</summary>`;
  const openAttr = allDone ? "" : " open";  // 全完成默认折叠,其余展开
  dock.innerHTML = `<details class="task-progress"${openAttr}>${summary}<div class="tp-list">${rows}</div></details>`;
  dock.classList.add("show");
}

function setTaskProgress(taskId, steps) {
  const normalized = cloneProgressSteps(steps);
  if (taskId) state.taskProgressByTask.set(taskId, normalized);
  if (state.taskId === taskId) renderTaskProgressDock(normalized);
}

// 卡片底部「复制」按钮 HTML。复制的是正文原文(Markdown 源),点击时经事件委托据
// 卡片 data-idx 回 state.loadedMessages 取 payload.content —— 不从渲染后的 .body HTML 反推。
// 直播卡不带此栏:流结束后 loadMessages 整屏重渲成持久卡,自然带上。
function msgActionsHtml() {
  return `<div class="msg-actions"><button type="button" class="msg-copy" title="复制正文">复制</button></div>`;
}

// 复制文本到剪贴板:优先 async Clipboard API(需 https/localhost),失败降级 execCommand。
// 按钮态短暂反馈「✓ 已复制 / 复制失败」后复位。
async function copyTextToClipboard(text, btn) {
  if (!text) return;
  let ok = false;
  try {
    if (navigator.clipboard && navigator.clipboard.writeText) {
      await navigator.clipboard.writeText(text);
      ok = true;
    }
  } catch (e) { /* 落降级路径 */ }
  if (!ok) {
    try {
      const ta = document.createElement("textarea");
      ta.value = text;
      ta.style.cssText = "position:fixed;top:0;left:0;opacity:0;";
      document.body.appendChild(ta);
      ta.select();
      ok = document.execCommand("copy");
      ta.remove();
    } catch (e) { ok = false; }
  }
  if (btn) {
    if (!btn.dataset.label) btn.dataset.label = btn.textContent;
    btn.textContent = ok ? "✓ 已复制" : "复制失败";
    btn.classList.toggle("copied", ok);
    clearTimeout(btn._copyTimer);
    btn._copyTimer = setTimeout(() => {
      btn.textContent = btn.dataset.label;
      btn.classList.remove("copied");
    }, 1400);
  }
}

function renderMessages(msgs) {
  const wrap = $("chat-stream");
  _msgScrollObserver.disconnect();  // 旧 sentinel 随 innerHTML 清掉,先断开避免悬挂 observe
  wrap.innerHTML = "";
  if (!msgs.length) {
    state.loadedMessages = [];
    state.msgHasMore = false;
    setTaskProgress(state.taskId, []);
    wrap.innerHTML = `<div class="empty">(暂无消息 · 在下方输入开始对话)</div>`;
    renderPersistedRunTerminal();
    renderLiveRunIfVisible();
    return;
  }
  // 还有更早 → 顶部放 sentinel,进视口自动加载(见 _msgScrollObserver)
  if (state.msgHasMore) {
    const sentinel = document.createElement("div");
    sentinel.className = "msg-top-sentinel muted";
    sentinel.textContent = state.msgLoadingEarlier ? "加载更早…" : "↑ 上滑加载更早";
    wrap.appendChild(sentinel);
    _msgScrollObserver.observe(sentinel);
  }
  // 模型切换点小标:assistant 行的 model_profile 与上一个 assistant 不同就插一行分隔
  // (含首条);避免每条都标制造噪声。空 model_profile(历史旧数据)不画。
  let lastAsstModel = null;
  // chip 去重:同一路径在 tool 结果里挂过 inline 图后,assistant 正文 echo 同路径不再重挂。
  // chronological 遍历,首次出现保留(tool 结果常在前),后续重复过滤掉。
  const seenRels = new Set();
  let currentProgressSteps = [];
  const pickFresh = (rels) => {
    const fresh = [];
    for (const r of rels) {
      if (seenRels.has(r)) continue;
      seenRels.add(r);
      fresh.push(r);
    }
    return fresh;
  };
  for (let mi = 0; mi < msgs.length; mi++) {
    const m = msgs[mi];
    const p = m.payload || {};
    const role = p.role || "?";
    if (role === "system") continue;  // 不显示 system
    if (role === "assistant" && m.model_profile && m.model_profile !== lastAsstModel) {
      const dn = (state.models.find(x => x.profile === m.model_profile) || {}).display_name || m.model_profile;
      const sep = document.createElement("div");
      sep.className = "model-switch muted";
      sep.style.cssText = "margin:8px 0;text-align:center;font-size:11px;letter-spacing:0.5px;";
      sep.textContent = `── ${dn} ──`;
      wrap.appendChild(sep);
      lastAsstModel = m.model_profile;
    }
    if (role === "tool") {
      if ((p.name || "") === "task_progress") continue;
      if ((p.name || "") === "ask_user") continue;  // 占位结果不展示;选项卡在 assistant tool_call 里渲染
      // 嵌进上一个 assistant 的 tool_call(简化:直接独立显示)
      const card = document.createElement("div");
      card.className = "msg tool";
      card.dataset.idx = m.idx;
      const txt = typeof p.content === "string" ? p.content : JSON.stringify(p.content);
      const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir);
      const banner = extractMediaBanner(p.name || "", txt || "");
      // 工具结果只有产物工具(seedream/seedance)挂 chip + inline 大图;通用工具
      // (grep/read/glob/shell)echo 的路径是"引用"不是"产物",不挂以免噪声。
      const isProducer = ARTIFACT_PRODUCING_TOOLS.has(p.name || "");
      const rels = isProducer ? pickFresh(extractArtifactRels(txt || "", wd)) : [];
      card.innerHTML = `
        <div class="role">工具调用 · ${escapeHtml(p.name || "")}</div>
        <details class="tool-call"><summary>结果(${(txt || "").length} 字符)${banner}</summary><pre>${escapeHtml(txt || "")}</pre></details>
        ${renderArtifactBarHtml(rels, isProducer)}
      `;
      // bg proc 启动结果 → 卡片活化(spinner/跳秒/停止,与直播态同构;真实状态
      // 由 selectTask 尾部的 refreshProcs 校正,终态卡显示 exit/耗时定格)
      decorateBgprocCard(card.querySelector("details.tool-call"), txt || "");
      wrap.appendChild(card);
      continue;
    }
    const card = document.createElement("div");
    card.className = "msg " + role;
    card.dataset.idx = m.idx;
    const roleLabel = { user: "我", assistant: "助手", error: "错误" }[role] || role;
    let html = `<div class="role">${roleLabel}</div>`;
    // 思考过程(thinking 模型持久化的 reasoning_content):默认折叠,与直播态同构
    if (role === "assistant") {
      const rc = (typeof p.reasoning_content === "string" && p.reasoning_content)
        || ((p.provider_specific_fields || {}).reasoning_content || "");
      if (rc) {
        html += `<details class="reasoning"><summary>思考过程</summary><div class="reason-body">${escapeHtml(rc)}</div></details>`;
      }
    }
    if (typeof p.content === "string" && p.content) {
      html += `<div class="body">${renderMd(p.content)}</div>`;
      // assistant 正文里 echo 的 <wd>/... 路径**永远**挂 chip(绕开 seenRels —— 上面
      // tool 结果可能 inline 过同图,但 chip 是小按钮无视觉污染,助手回复里有可
      // 点的"产物锚点"比没有好);强制 allowInlineMedia=false 防止大图被重复 inline。
      if (role === "assistant") {
        const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir);
        html += renderArtifactBarHtml(extractArtifactRels(p.content, wd), false);
      }
    }
    if (Array.isArray(p.tool_calls) && p.tool_calls.length) {
      const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir);
      const progressResult = progressActionsFromToolCalls(p.tool_calls, currentProgressSteps);
      currentProgressSteps = progressResult.steps;
      for (const tc of p.tool_calls) {
        const fn = (tc.function && tc.function.name) || "?";
        let argsObj = {};
        let args = "";
        try {
          argsObj = JSON.parse((tc.function && tc.function.arguments) || "{}");
          args = JSON.stringify(argsObj, null, 2);
        } catch (e) { args = (tc.function && tc.function.arguments) || ""; }
        if (fn === "task_progress") {
          continue;
        }
        if (fn === "ask_user") {
          // 之后若已有 user 消息 → 这轮选择已答:卡置灰,命中项标「已选」;否则仍可点。
          let answered = false, chosen = "";
          for (let k = mi + 1; k < msgs.length; k++) {
            if (((msgs[k].payload || {}).role) === "user") {
              answered = true;
              chosen = (msgs[k].payload.content || "");
              break;
            }
          }
          html += buildAskUserCard(argsObj, { interactive: !answered, chosenLabel: chosen }).outerHTML;
          continue;
        }
        const label = toolActivityLabel(fn, argsObj);
        const isProducer = ARTIFACT_PRODUCING_TOOLS.has(fn);
        const rels = isProducer ? pickFresh(extractArtifactRels(args, wd)) : [];
        html += `
          <details class="tool-call"><summary>${escapeHtml(label)}</summary><pre>${escapeHtml(args)}</pre></details>
          ${renderArtifactBarHtml(rels, isProducer)}
        `;
      }
      // 进度不再内联进消息卡 —— 累积值在循环末统一喂顶部 dock(见 setTaskProgress)
    }
    // 复制栏:user 恒有正文;assistant 仅在有正文文本时挂(纯 tool_calls 轮无正文可复制)
    const hasCopy = role === "user"
      || (role === "assistant" && typeof p.content === "string" && p.content.trim());
    if (hasCopy) html += msgActionsHtml();
    card.innerHTML = html;
    highlightIn(card);
    wrap.appendChild(card);
  }
  // 底部 sentinel:从目录跳到旧消息后,下方还有更新的未加载 → 进视口自动向下补
  if (state.msgHasMoreNewer) {
    const sb = document.createElement("div");
    sb.className = "msg-bot-sentinel muted";
    sb.textContent = state.msgLoadingNewer ? "加载更新…" : "↓ 下滑加载更新";
    wrap.appendChild(sb);
    _msgScrollObserver.observe(sb);
  }
  wrap.scrollTop = wrap.scrollHeight;
  setTaskProgress(state.taskId, currentProgressSteps);
  upgradeMediaArtifacts(wrap);
  renderPersistedRunTerminal();  // 上次 run error/cancelled 终态 → 末尾补持久卡(所有重渲路径统一走这)
  renderLiveRunIfVisible();
}

// ───── send + SSE ─────
// 发送 / 停止 单按钮:idle → 发送(primary 红实心);streaming → 停止(danger 红边);
// cancelling 是过渡态 — 用户点过停止后到 SSE 收到 cancelled/done 之间。
function setActionMode(mode) {
  const btn = $("chat-action");
  btn.classList.remove("primary", "danger");
  if (mode === "idle") {
    btn.textContent = "发送";
    btn.classList.add("primary");
    btn.disabled = false;
    btn.title = "";
  } else if (mode === "streaming") {
    btn.textContent = "停止";
    btn.classList.add("danger");
    btn.disabled = false;
    btn.title = "停止当前流式回复";
  } else if (mode === "cancelling") {
    btn.textContent = "停止中…";
    btn.classList.add("danger");
    btn.disabled = true;
  } else if (mode === "bgproc") {
    // 后台进程运行期:对话锁定,观感与前台执行一致(发送→停止)。后台化的收益
    // 定位是"进程扛超时/服务重启",不改变"一个任务同时只做一件事"的对话心智。
    btn.textContent = "停止";
    btn.classList.add("danger");
    btn.disabled = false;
    btn.title = "终止后台进程(已产生的输出会保留)";
  } else if (mode === "loading") {
    // 切 task / 刷新的加载窗口:先锁后放(悲观默认),状态到齐再切真实模式
    btn.textContent = "发送";
    btn.classList.add("primary");
    btn.disabled = true;
  }
}

// 无直播 run 时的 composer 状态:有本 task 的 running 后台进程 → 锁定(bgproc),
// 否则 idle。procs.js 每次拉取后回调,进程结束的那次拉取在这里解锁。
let _bgprocLocked = false;
export function syncBgprocLock() {
  if (!state.taskId || getLiveRun(state.taskId)) return;  // 直播 run 拥有按钮
  if (hasRunningProc(state.taskId)) {
    setActionMode("bgproc");
    // hint 只在进入锁定那一刻写一次 —— 5s 轮询重复写会盖掉"已转写"/"润色中"
    // 等临时提示(润色/语音在锁定期与 streaming 期一样可用,只编辑草稿不发送)
    if (!_bgprocLocked) {
      _bgprocLocked = true;
      $("chat-hint").textContent = "后台进程运行中,完成后可继续对话…";
    }
  } else if (_bgprocLocked) {
    _bgprocLocked = false;
    setActionMode("idle");
    $("chat-hint").textContent = "";
  }
}

function chatAction() {
  if (isCurrentTaskStreaming()) { cancelCurrentTask(); return; }
  if (hasRunningProc(state.taskId)) { killTaskProcs(state.taskId); return; }
  sendMessage();
}

$("chat-form").addEventListener("submit", (e) => { e.preventDefault(); chatAction(); });
$("chat-input").addEventListener("keydown", (e) => {
  // streaming 期间 Enter 不触发停止 —— 用户可能正在编辑下一条草稿,误触发风险高
  if (e.key === "Enter" && !e.shiftKey) {
    e.preventDefault();
    // streaming / 后台进程锁定 / 加载态期间 Enter 不发送(与按钮语义一致,防误触)
    if ($("chat-action").disabled) return;
    if (!isCurrentTaskStreaming() && !hasRunningProc(state.taskId)) sendMessage();
  }
});
$("chat-input").addEventListener("input", syncOptimizeBtn);
// 粘贴 / 拖拽含文件 → 上传到当前目录,chip 累积进 #chat-attach 托盘(与状态文字解耦,
// 避免上传进度 / 下一次粘贴把已有 chip 顶掉)。状态反馈仍走 #chat-hint;纯文本粘贴走默认。
$("chat-input").addEventListener("paste", (e) => {
  const files = Array.from(e.clipboardData?.files || []);
  if (!files.length) return;
  e.preventDefault();
  uploadAttachFiles(files);
});

// 拖拽落点 = 整个 #chat-form(命中面积大);用 enter/leave 计数防子元素冒泡时高亮闪烁。
// 只认文件拖拽(_hasFiles),只读镜像(微信渠道)不接收。
let _composerDragDepth = 0;
function _dragHasFiles(ev) {
  const t = ev.dataTransfer;
  return !!(t && t.types && [...t.types].includes("Files"));
}
function _composerLocked() {
  const input = $("chat-input");
  return !!(input && input.readOnly);
}
$("chat-form").addEventListener("dragenter", (e) => {
  if (_composerLocked() || !_dragHasFiles(e)) return;
  e.preventDefault();
  _composerDragDepth++;
  $("chat-form").classList.add("drag-over");
});
$("chat-form").addEventListener("dragover", (e) => {
  if (_composerLocked() || !_dragHasFiles(e)) return;
  e.preventDefault();
  e.dataTransfer.dropEffect = "copy";
});
$("chat-form").addEventListener("dragleave", (e) => {
  if (!_dragHasFiles(e)) return;
  _composerDragDepth = Math.max(0, _composerDragDepth - 1);
  if (_composerDragDepth === 0) $("chat-form").classList.remove("drag-over");
});
$("chat-form").addEventListener("drop", (e) => {
  if (_composerLocked() || !_dragHasFiles(e)) return;
  e.preventDefault();
  _composerDragDepth = 0;
  $("chat-form").classList.remove("drag-over");
  const files = Array.from(e.dataTransfer.files || []);
  if (files.length) uploadAttachFiles(files);
});

// 上传一批文件并把结果 chip 追加进托盘。状态写 #chat-hint(进度 / 已上传),不碰 chip。
async function uploadAttachFiles(files) {
  if (!files || !files.length) return;
  const hint = $("chat-hint");
  hint.textContent = files.length === 1 ? `上传中:${files[0].name}…` : `上传中:${files.length} 个文件…`;
  const saved = await uploadFiles(files, {
    onProgress: (loaded, total) => {
      hint.textContent = formatUploadProgress(files, loaded, total);
    },
  });
  if (saved && saved.length) {
    addAttachChips(saved);
    const n = attachCount();
    hint.innerHTML = `已添加 ${saved.length} 个文件,共 ${n} 个待发送 <span class="muted">可在右侧文件处查看</span>`;
  }
  // 失败时 uploadFiles 内部已 alert;hint 保留"上传中…"文字也无碍(下次发送会覆盖)
}

function attachTray() { return $("chat-attach"); }
function attachWraps() {
  const tray = attachTray();
  return tray ? Array.from(tray.querySelectorAll(".paste-chip-wrap[data-rel]")) : [];
}
function attachCount() { return attachWraps().length; }
// 切 task 时清掉上个 task 残留的未发送 chip(它们指向上个 task_dir,新 task 用不上)。
// 只清 DOM,不删已上传的文件(用户可能切回去发,文件还在原目录)。
function clearAttachTray() {
  const tray = attachTray();
  if (!tray) return;
  tray.innerHTML = "";
  tray.classList.remove("show");
}

// 追加 chip,按 rel 去重(同一文件重复粘贴/拖拽只保留一个),并显示托盘。
function addAttachChips(saved) {
  const tray = attachTray();
  if (!tray) return;
  const existing = new Set(attachWraps().map((w) => w.dataset.rel));
  for (const f of saved || []) {
    const rel = f.rel || f.name || "";
    if (!rel || existing.has(rel)) continue;
    existing.add(rel);
    const name = f.name || (rel.split("/").pop() || rel);
    tray.insertAdjacentHTML("beforeend",
      `<span class="paste-chip-wrap" data-rel="${escapeHtml(rel)}"><button type="button" class="art-chip paste-chip" data-rel="${escapeHtml(rel)}" title="${escapeHtml(rel)} · 点击预览">${escapeHtml(name)}</button><button type="button" class="paste-chip-del" data-rel="${escapeHtml(rel)}" title="删除该文件">×</button></span>`);
  }
  tray.classList.toggle("show", attachCount() > 0);
}

attachTray().addEventListener("click", (e) => {
  const del = e.target.closest && e.target.closest(".paste-chip-del[data-rel]");
  if (del) {
    e.stopPropagation();
    deletePastedFile(del.dataset.rel, del.closest(".paste-chip-wrap"));
    return;
  }
  const chip = e.target.closest && e.target.closest(".paste-chip[data-rel]");
  if (!chip) return;
  const rel = chip.dataset.rel;
  if (rel) openPasteFilePreview(rel);
});

async function deletePastedFile(rel, wrap) {
  if (!rel || !wrap) return;
  const btn = wrap.querySelector(".paste-chip-del");
  if (btn) btn.disabled = true;
  try {
    await api("POST", "/v1/files/delete", { path: rel, recursive: false });
    closePreviewIfShowing(rel);
    wrap.remove();
    await loadFiles();
    const tray = attachTray();
    if (tray) tray.classList.toggle("show", attachCount() > 0);
    if (attachCount() === 0) {
      $("chat-hint").innerHTML = `<span class="muted">已删除文件</span>`;
    }
  } catch (e) {
    if (btn) btn.disabled = false;
    if (e.status === 401) { logout(); return; }
    message("删除失败:" + e.message, "error");
  }
}

// 渠道镜像 task(微信 / 企业微信)在 web 端只读:这条对话的唯一交互入口锚定在对应 App ——
// 微信侧 agent 回复必须带 context_token 才发得回,token 只能从用户入站消息拿(24h 过期),
// 协议层没有无条件说话的能力;企业微信虽可主动推,但同样把交互权威收敛在 App 端,避免
// web/手机两路输入打架。故 web→渠道单向不同步,web 端做干净的只读镜像(单一交互权威 +
// 可预测),想主动推走 wechat_push / 定时简报。渠道→web 仍同步(同一条 task)。
function applyChannelComposerLock(meta) {
  const input = $("chat-input");
  if (!input) return;
  const cfg = meta && channelCfg(meta.channel);  // 微信 / 企业微信镜像 → 只读
  input.readOnly = !!cfg;
  input.classList.toggle("readonly-locked", !!cfg);
  input.placeholder = cfg
    ? `${cfg.label}对话请在${cfg.label}里进行 — web 端为只读镜像,可查看历史`
    : "输入消息…(Enter 发送,Shift+Enter 换行,可粘贴 / 拖拽文件)";
  if (cfg) {
    const opt = $("chat-optimize");
    if (opt) opt.disabled = true;
  }
  const mic = $("chat-mic");
  if (mic) mic.disabled = !!cfg;  // 只读镜像没有发送入口,语音输入同样锁掉
}

// 润色:同步调后端,把 textarea 内容替成优化后文本。用 execCommand('insertText')
// 接 textarea 原生 undo 栈 — Ctrl+Z 一次回到原文。streaming 期间允许并行(后端
// 不与主对话 run 互斥,各跑各的 LLM)。
function syncOptimizeBtn() {
  const btn = $("chat-optimize");
  if (!btn) return;
  if (state.optimizing) return;  // 进行中不在这条路径切
  const has = ($("chat-input").value || "").trim().length > 0;
  btn.disabled = !has || !state.taskId;
}

async function optimizePrompt() {
  if (state.optimizing) return;
  if (!state.taskId) return;
  const ta = $("chat-input");
  const original = (ta.value || "").trim();
  if (!original) return;
  const btn = $("chat-optimize");
  state.optimizing = true;
  btn.disabled = true;
  const oldLabel = btn.textContent;
  btn.textContent = "润色中…";
  const oldHint = $("chat-hint").textContent;
  $("chat-hint").textContent = "润色中…";
  try {
    const r = await api("POST", `/v1/tasks/${state.taskId}/optimize_prompt`, {
      text: ta.value,  // 不 trim — 后端再 strip;保留尾部 newline 让用户感受不变
      image_model: state.imageModel || "",
      video_model: state.videoModel || "",
    });
    const optimized = (r.optimized || "").trim();
    if (!optimized) throw new Error("空结果");
    // execCommand('insertText') 把"全选 + 替换"作为一个 undo 单元接入 textarea 原生栈
    ta.focus();
    ta.select();
    const ok = document.execCommand("insertText", false, optimized);
    if (!ok) {
      // execCommand 在某些环境(contentEditable=false 旧 Firefox)失败 — 兜底直接赋值
      // 这种情况下 Ctrl+Z 失效,但功能不阻塞;贴提示让用户知道
      ta.value = optimized;
      $("chat-hint").textContent = "已润色(本浏览器不支持撤销,需自行保留草稿)";
    } else {
      const cost = typeof r.cost_cny === "number" ? r.cost_cny.toFixed(4) : "?";
      $("chat-hint").textContent = `已润色 · ${r.tokens_in || 0}+${r.tokens_out || 0} tok · ¥${cost} · Ctrl+Z 撤销`;
    }
  } catch (e) {
    if (e.status === 401) { logout(); return; }
    $("chat-hint").textContent = `润色失败:${e.message}`;
  } finally {
    state.optimizing = false;
    btn.textContent = oldLabel;
    syncOptimizeBtn();
  }
}

$("chat-optimize").onclick = optimizePrompt;

// ── 语音输入(讯飞 IAT)────────────────────────────────────────────────────────
// 采集:Web Audio ScriptProcessor 采 PCM。转写走双通道:
// - 流式(主通道):录音开始即连 WS /v1/asr/stream(首消息发 JWT),每片音频增量降采样
//   16k/16bit 实时上行,服务端转发讯飞并回推 partial 全文(wpgs 动态修正在服务端合并),
//   录音面板 #vr-live 实时字幕;停止只需等最终帧(百 ms 级)—— 消掉旧"整段按 40ms/帧
//   回放给讯飞"造成的按时长等比例停顿。
// - 整段(兜底):WS 连不上(老服务端/网络)或中途挂 → 停止后把累积 PCM POST
//   /v1/asr/transcribe(原链路保留)。
// 最终文本插到输入框光标处(insertText 接原生 undo 栈,不自动发送)。
// 手势两端统一(PC 鼠标 / 手机触屏同一心智模型),按下时长判别,共用同一条
// 采集/转写链路(录音需 HTTPS / localhost;60s 讯飞上限自动停):
// - 按住(≥300ms)= 按住说话:松开即转写,上滑超阈值松开取消(微信 / DeepSeek 式)。
// - 快速点一下(<300ms)= 连续录音(免持,长口述不用一直按着 —— 鼠标按住 60s 会累且
//   易失手):说完 Enter / 再点一下结束并转写,Esc 取消。
//   注:不做 VAD 静音自动停 —— 口述任务需求时"边想边说"停顿 3~5s 是常态,自动截断
//   比多按一下更伤;业界听写类(ChatGPT/Google Docs/Win+H)也全是手动收尾。
// 录音期 #voice-rec-panel 替换 textarea:实时波浪(录音 chunk 顺手算 RMS,零额外
// 采集开销)+ 计时 + 手势提示。
const ASR_MAX_SECONDS = 60;
const HOLD_CANCEL_PX = 60;    // 按住说话:上滑多远进入"松开取消"
let _voice = null;  // 录音态:{stream,ctx,source,node,chunks,levels,rate,t0,timer,raf,mode,startY,cancelArmed}
let _holdReleased = false;    // 手指在 getUserMedia 授权完成前已松开 → 启动完成后立即收尾
let _suppressClickUntil = 0;  // touch 序列结束后浏览器补发的合成 click,不当 toggle 用

// 线性插值降采样到 16k 并量化 Int16 小端(讯飞只收 16k/16bit/mono raw)
function _pcm16kFromChunks(chunks, srcRate) {
  let total = 0;
  for (const c of chunks) total += c.length;
  const src = new Float32Array(total);
  let off = 0;
  for (const c of chunks) { src.set(c, off); off += c.length; }
  const ratio = srcRate / 16000;
  const out = new Int16Array(Math.floor(src.length / ratio));
  for (let i = 0; i < out.length; i++) {
    const pos = i * ratio, i0 = Math.floor(pos), i1 = Math.min(i0 + 1, src.length - 1);
    const s = src[i0] + (src[i1] - src[i0]) * (pos - i0);
    out[i] = Math.max(-1, Math.min(1, s)) * 0x7fff;
  }
  return out;
}

// 增量重采样器:逐片喂 Float32 原始采样,吐 16k Int16;片间用 carry 缓冲保持插值连续
// (每片独立重采样会在拼缝处引入细微断裂,长音频里累积影响识别)。
function _makeResampler16k(srcRate) {
  const ratio = srcRate / 16000;
  let carry = new Float32Array(0);
  return (chunk) => {
    let src = chunk;
    if (carry.length) {
      src = new Float32Array(carry.length + chunk.length);
      src.set(carry, 0);
      src.set(chunk, carry.length);
    }
    const n = Math.max(0, Math.floor((src.length - 1) / ratio));
    const out = new Int16Array(n);
    for (let i = 0; i < n; i++) {
      const pos = i * ratio, i0 = Math.floor(pos);
      const s = src[i0] + (src[i0 + 1] - src[i0]) * (pos - i0);
      out[i] = Math.max(-1, Math.min(1, s)) * 0x7fff;
    }
    carry = src.slice(Math.floor(n * ratio));
    return out;
  };
}

function _vrHint(text) { $("vr-hint").textContent = text; }

// 转写结果统一入口:光标处插入(不覆盖已有草稿);execCommand 接 textarea 原生 undo 栈
function _insertTranscript(text) {
  const ta = $("chat-input");
  ta.focus();
  const ok = document.execCommand("insertText", false, text);
  if (!ok) ta.value += text;
  syncOptimizeBtn();
  $("chat-hint").textContent = "已转写,确认后发送";
}

function _drawWave() {
  if (!_voice) return;
  const cv = $("vr-wave"), g = cv.getContext("2d");
  const W = cv.width, H = cv.height, dpr = window.devicePixelRatio || 1;
  g.clearRect(0, 0, W, H);
  g.fillStyle = _voice.cancelArmed ? "#fff" : "#d33";
  const bw = 3 * dpr, gap = 2 * dpr;
  const lv = _voice.levels.slice(-Math.floor(W / (bw + gap)));
  for (let i = 0; i < lv.length; i++) {
    const h = Math.max(2 * dpr, Math.min(1, lv[i] * 6) * H);
    g.fillRect(W - (lv.length - i) * (bw + gap), (H - h) / 2, bw, h);
  }
  _voice.raf = requestAnimationFrame(_drawWave);
}

async function startVoiceInput(mode, startY) {
  if (_voice) return;
  const btn = $("chat-mic");
  if (!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia)) {
    $("chat-hint").textContent = "此环境不支持录音(需 HTTPS 或 localhost 访问)";
    return;
  }
  let stream;
  try {
    stream = await navigator.mediaDevices.getUserMedia({
      audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true },
    });
  } catch (e) {
    $("chat-hint").textContent = "无法使用麦克风:" +
      (e.name === "NotAllowedError" ? "请在浏览器里允许麦克风权限" : e.message);
    return;
  }
  const ctx = new (window.AudioContext || window.webkitAudioContext)();
  try { await ctx.resume(); } catch (e) {}  // iOS Safari:手势内 resume 才真正开跑
  const source = ctx.createMediaStreamSource(stream);
  // ScriptProcessor 已标废弃但全浏览器可用;AudioWorklet 要独立模块文件,纯静态 JS 下不值当
  const node = ctx.createScriptProcessor(4096, 1, 1);
  const chunks = [], levels = [];
  const vo = { stream, ctx, source, node, chunks, levels, rate: ctx.sampleRate,
               t0: Date.now(), timer: 0, raf: 0, mode, startY: startY || 0, cancelArmed: false,
               // 流式通道状态:wsDead 置位后 stop 走整段 POST 兜底;finalText/finalResolve
               // 承接服务端 final 帧(可能先于用户松手 —— 静音超 10s / 60s 引擎自行收尾)
               ws: null, wsDead: false, live: "", finalText: null, finalResolve: null,
               resample: _makeResampler16k(ctx.sampleRate) };
  _voice = vo;
  node.onaudioprocess = (ev) => {
    const buf = new Float32Array(ev.inputBuffer.getChannelData(0));
    chunks.push(buf);  // 全量累积:流式挂了停止时还能整段 POST 兜底
    let sum = 0, n = 0;
    for (let i = 0; i < buf.length; i += 8) { sum += buf[i] * buf[i]; n++; }  // 1/8 抽样够画波浪
    levels.push(Math.sqrt(sum / n));
    if (vo.ws && vo.ws.readyState === 1 && !vo.wsDead) {
      const out = vo.resample(buf);
      if (out.length) { try { vo.ws.send(out.buffer); } catch (e) { vo.wsDead = true; } }
    }
  };
  source.connect(node);
  node.connect(ctx.destination);  // 不接 destination 部分浏览器不触发 onaudioprocess;输出恒为静音无回声
  // 流式通道:失败不阻塞录音(采集照跑),只标 wsDead 让停止走兜底
  try {
    const ws = new WebSocket((location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/v1/asr/stream");
    ws.onopen = () => { try { ws.send(JSON.stringify({ token: state.token })); } catch (e) { vo.wsDead = true; } };
    ws.onmessage = (ev) => {
      let m = null;
      try { m = JSON.parse(ev.data); } catch (e) { return; }
      if (m.error) { vo.wsDead = true; try { ws.close(); } catch (e) {} return; }
      vo.live = m.text || "";
      if (_voice === vo) $("vr-live").textContent = vo.live;  // 实时字幕
      if (m.final) {
        vo.finalText = vo.live;
        if (vo.finalResolve) vo.finalResolve(vo.finalText);
        else if (_voice === vo) stopVoiceInput(false);  // 引擎提前收尾 → 自动结束录音
      }
    };
    ws.onerror = () => { vo.wsDead = true; };
    ws.onclose = () => {
      if (vo.finalText == null) vo.wsDead = true;  // 没等到 final 就关了 = 通道废
      if (vo.finalResolve) vo.finalResolve(vo.finalText);  // 别让 stop 干等超时
    };
    vo.ws = ws;
  } catch (e) { vo.wsDead = true; }
  $("vr-live").textContent = "";
  $("chat-form").classList.add("recording");
  $("voice-rec-panel").classList.remove("cancel-armed");
  const cv = $("vr-wave"), dpr = window.devicePixelRatio || 1;
  cv.width = Math.max(1, cv.clientWidth * dpr);   // 面板刚显示,读 clientWidth 强制同步布局
  cv.height = Math.max(1, cv.clientHeight * dpr);
  _vrHint(mode === "hold" ? "松开转写 · 上滑取消" : "说完按 Enter 结束 · Esc 取消");
  btn.classList.add("recording");
  if (mode === "click") btn.textContent = "■ 停止";
  const tick = () => {
    if (!_voice) return;
    const sec = Math.floor((Date.now() - _voice.t0) / 1000);
    $("vr-time").textContent = `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, "0")}`;
    if (sec >= ASR_MAX_SECONDS) { stopVoiceInput(false); return; }
    _voice.timer = setTimeout(tick, 500);
  };
  tick();
  _drawWave();
  $("chat-hint").textContent = "录音中…";
  // 按住说话但 getUserMedia 完成前已松开(快速点按 / 授权弹窗期间松手):
  // 短按 → 照常转成连续录音;长按(多半是授权弹窗耗掉的)→ 按"松开"收尾(太短走取消)
  if (mode === "hold" && _holdReleased) {
    if (Date.now() - _pressT0 < HOLD_TOGGLE_MS && !_voice.cancelArmed) {
      _voice.mode = "click";
      btn.textContent = "■ 停止";
      _vrHint("说完按 Enter 结束 · Esc 取消");
    } else {
      stopVoiceInput(_voice.cancelArmed);
    }
  }
}

async function stopVoiceInput(discard) {
  const v = _voice;
  if (!v) return;
  _voice = null;
  clearTimeout(v.timer);
  cancelAnimationFrame(v.raf);
  const btn = $("chat-mic");
  btn.classList.remove("recording");
  btn.textContent = "🎙 语音";
  $("chat-form").classList.remove("recording");
  $("voice-rec-panel").classList.remove("cancel-armed");
  $("vr-time").textContent = "0:00";
  try { v.source.disconnect(); v.node.disconnect(); } catch (e) {}
  v.stream.getTracks().forEach((t) => t.stop());
  try { await v.ctx.close(); } catch (e) {}
  $("vr-live").textContent = "";
  if (discard) {
    if (v.ws) { try { v.ws.close(); } catch (e) {} }  // 服务端见 disconnect 即丢弃会话
    $("chat-hint").textContent = "已取消";
    return;
  }
  const pcm = _pcm16kFromChunks(v.chunks, v.rate);
  if (pcm.length < 16000 / 4) {  // < 0.25s:多半是误触
    if (v.ws) { try { v.ws.close(); } catch (e) {} }
    $("chat-hint").textContent = "录音太短,已取消";
    return;
  }
  // ── 流式通道健在:发 end 只等最终帧(音频早已实时上行)──
  if (v.ws && !v.wsDead && (v.finalText != null || v.ws.readyState === 1)) {
    $("chat-hint").textContent = "转写中…";
    const text = v.finalText != null ? v.finalText : await new Promise((res) => {
      v.finalResolve = res;  // ws.onmessage(final) / onclose 会 resolve
      try { v.ws.send(JSON.stringify({ type: "end" })); } catch (e) { res(null); }
      setTimeout(() => res(null), 10000);  // 超时兜底 → 落整段 POST
    });
    try { v.ws.close(); } catch (e) {}
    if (text != null) {
      if (!text.trim()) { $("chat-hint").textContent = "没有识别到内容,请重试"; return; }
      _insertTranscript(text.trim());
      return;
    }
  }
  // ── 整段 POST 兜底(WS 没连上 / 中途挂 / 等最终帧超时)──
  btn.disabled = true;
  $("chat-hint").textContent = "转写中…";
  try {
    const r = await fetch("/v1/asr/transcribe", {
      method: "POST",
      headers: { "Authorization": "Bearer " + state.token, "Content-Type": "application/octet-stream" },
      body: pcm.buffer,
    });
    if (r.status === 401) { logout(); return; }
    const data = await r.json().catch(() => null);
    if (!r.ok) throw new Error((data && data.detail) || r.status + " " + r.statusText);
    const text = ((data && data.text) || "").trim();
    if (!text) { $("chat-hint").textContent = "没有识别到内容,请重试"; return; }
    _insertTranscript(text);
  } catch (e) {
    $("chat-hint").textContent = "转写失败:" + e.message;
  } finally {
    btn.disabled = false;
  }
}

// 手势接线(两端统一,按下时长判别):按住 ≥300ms = 按住说话(松开转写);短按 <300ms
// = 转成连续录音(免持)。press 发起的录音,其松手后浏览器补发的合成 click 一律压掉 ——
// 否则"松开转写完又立刻开新录音"/"短按刚转免持就被自己的 click 停掉"。点按钮停止连续
// 录音的那次 press 不压(pointerdown 因 _voice 存在没发起录音,_pressActive=false)。
// 键盘激活按钮(Space/Enter,无 pointer 事件)兜底走 click 起录。
const HOLD_TOGGLE_MS = 300;
const _micBtn = $("chat-mic");
let _pressT0 = 0;          // pointerdown 时刻,判短按/长按
let _pressActive = false;  // 本次 press 确实发起了录音

_micBtn.addEventListener("pointerdown", (e) => {
  e.preventDefault();  // 触屏压掉长按选中/滚动(CSS 已 touch-action:none);鼠标压掉拖选
  if (_voice || _micBtn.disabled) return;
  _pressActive = true;
  _pressT0 = Date.now();
  _holdReleased = false;
  try { _micBtn.setPointerCapture(e.pointerId); } catch (err) {}  // 滑出按钮仍收 move/up
  startVoiceInput("hold", e.clientY);
});
_micBtn.addEventListener("pointermove", (e) => {
  if (!_voice || _voice.mode !== "hold") return;
  const armed = (_voice.startY - e.clientY) > HOLD_CANCEL_PX;
  if (armed !== _voice.cancelArmed) {
    _voice.cancelArmed = armed;
    $("voice-rec-panel").classList.toggle("cancel-armed", armed);
    _vrHint(armed ? "松开取消" : "松开转写 · 上滑取消");
  }
});
const _holdEnd = (e) => {
  if (!_pressActive) return;
  _pressActive = false;
  _suppressClickUntil = Date.now() + 700;
  _holdReleased = true;
  if (!_voice || _voice.mode !== "hold") return;  // 还在 getUserMedia → startVoiceInput 末尾兜底
  if (e.type !== "pointercancel" && !_voice.cancelArmed && Date.now() - _pressT0 < HOLD_TOGGLE_MS) {
    // 短按 → 连续录音(免持):说完 Enter / 再点一下结束
    _voice.mode = "click";
    _micBtn.textContent = "■ 停止";
    _vrHint("说完按 Enter 结束 · Esc 取消");
    return;
  }
  stopVoiceInput(_voice.cancelArmed || e.type === "pointercancel");
};
_micBtn.addEventListener("pointerup", _holdEnd);
_micBtn.addEventListener("pointercancel", _holdEnd);
_micBtn.addEventListener("contextmenu", (e) => e.preventDefault());  // 长按不出系统菜单
_micBtn.addEventListener("click", () => {
  if (Date.now() < _suppressClickUntil) return;
  if (_voice) { if (_voice.mode === "click") stopVoiceInput(false); }
  else startVoiceInput("click");  // 仅键盘激活可达(press 路径的 click 已被压掉)
});
// 桌面录音收尾快捷键(capture 拦截,防 Enter 落回 textarea 触发发消息)
document.addEventListener("keydown", (e) => {
  if (!_voice || _voice.mode !== "click") return;
  if (e.key === "Enter") { e.preventDefault(); e.stopPropagation(); stopVoiceInput(false); }
  else if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); stopVoiceInput(true); }
}, true);

// 对话流里 artifact chip / 内联 img 点击委托 — 复用右栏文件预览 modal(modal 内自带"下载")。
// 视频走原生 <video controls>:点击=播放/暂停,全屏走浏览器自带按钮,不进 modal —
// 弹个 modal 反而打断播放,不如交给浏览器。
$("chat-stream").addEventListener("click", (e) => {
  const copyBtn = e.target.closest && e.target.closest(".msg-copy");
  if (copyBtn) {
    const card = copyBtn.closest(".msg[data-idx]");
    const idx = card && card.dataset.idx;
    const m = idx != null && state.loadedMessages.find((x) => String(x.idx) === String(idx));
    const text = m && m.payload && typeof m.payload.content === "string" ? m.payload.content : "";
    copyTextToClipboard(text, copyBtn);
    return;
  }
  const askBtn = e.target.closest && e.target.closest(".ask-option");
  if (askBtn) {
    if (askBtn.disabled || isCurrentTaskStreaming()) return;
    const label = askBtn.dataset.label || "";
    const card = askBtn.closest(".ask-user");
    if (card) {
      card.classList.add("answered");
      card.querySelectorAll(".ask-option").forEach((b) => { b.disabled = true; });
    }
    askBtn.classList.add("selected");
    if (label) sendMessage(label);
    return;
  }
  const chip = e.target.closest && e.target.closest(".art-chip");
  if (chip) {
    const rel = chip.dataset.rel;
    if (rel) openFilePreview(rel);
    return;
  }
  const inlineImg = e.target.closest && e.target.closest(".art-media-image[data-rel]");
  if (inlineImg) {
    const rel = inlineImg.dataset.rel;
    if (rel) openFilePreview(rel);
    return;
  }
  // 正文里的 markdown 链接:模型常把工作区相对路径写成 [<rel>](<rel>),renderMd 出 <a>。
  // 无拦截时原生跳转会打到 API server 不存在的路由 → FastAPI 默认 {"detail":"Not Found"}
  // 整页糊脸。拦下来:外部链接开新标签,内部相对路径复用文件预览 modal(与 chip 同路径,
  // pptx 自动转 PDF 预览)。href 取 getAttribute 原样值(模型写的相对路径正好是 user_root 相对)。
  const link = e.target.closest && e.target.closest(".msg .body a[href]");
  if (link) {
    const raw = link.getAttribute("href") || "";
    if (!raw || raw.startsWith("#")) return;  // 页内锚点 / 空 → 走默认
    if (/^(https?:|mailto:|tel:|blob:|data:)/i.test(raw)) {
      e.preventDefault();
      window.open(raw, "_blank", "noopener");
      return;
    }
    e.preventDefault();
    // 渠道推送(企业微信/定时任务)把产物写成完整下载 API 链接 [名](/v1/files/download?path=<rel>)
    // —— 对企业微信客户端是"点即下载"的正解,但 web 这里若当普通相对路径处理,会把整个
    // `/v1/files/download?path=...` 串当 rel 二次塞进 download URL → 请求 path 不存在 → 404
    // (踩过 task 7f904e5f 的定时简报)。故先识别"已是 /v1/files 接口"的 href,抽出 path=
    // 查询参数作真正 rel 交预览(txt/docx 等一并复用同一 modal),抽取失败再回退旧逻辑。
    if (/^\/?v1\/files\//i.test(raw)) {
      try {
        const p = new URL(raw, location.origin).searchParams.get("path");
        if (p) { openFilePreview(p); return; }
      } catch (_) {}
    }
    let rel = raw.replace(/^sandbox:/i, "").replace(/^\.?\/+/, "");
    try { rel = decodeURIComponent(rel); } catch (_) {}
    rel = rel.replace(/^\/+/, "");
    if (rel) openFilePreview(rel);
    return;
  }
});

// POST /messages 退避重试:覆盖后端优雅 drain 的部署窗口 ——
//   ① 排空期老进程返 503(背压) ② 进程交接缺口 fetch 拒连(api 抛 TypeError,无 status)。
// 两种都重试,UI 显"服务更新中";~26s 预算内大多能等到新进程接手。仍失败则抛出由
// sendMessage 的 catch 贴友好提示,用户稍后重发。其它错误(4xx 等)立即抛不重试。
async function postMessageWithRetry(taskId, body) {
  const delays = [1000, 2000, 3000, 5000, 5000, 5000, 5000];  // 7 次 ≈ 26s
  for (let attempt = 0; ; attempt++) {
    try {
      return await api("POST", `/v1/tasks/${taskId}/messages`, body);
    } catch (e) {
      const retriable = e.status === 503 || e.name === "TypeError";  // 503 背压 / 网络拒连
      if (!retriable || attempt >= delays.length) throw e;
      $("chat-hint").textContent = "服务更新中,正在重发…";
      await new Promise((res) => setTimeout(res, delays[attempt]));
    }
  }
}

// overrideText:点 ask_user 选项时传入选项 label,直接作为用户消息发出(不读输入框、
// 不清空输入框 —— 用户可能正在输入框打讨论草稿)。无参数则走输入框(正常发送)。
// 收集 chat-hint 里的粘贴附件路径(粘贴图片已上传到 task_dir,chip 带 data-rel)。
// 返回路径数组并清掉 chip —— 这些路径要随消息正文发给模型,否则模型不知道用户贴了哪张图
// (改图 / 看图都靠它定位)。只在「从输入框发送」时取,ask_user 选项点击(overrideText)不带附件。
function takePastedRels() {
  const wraps = attachWraps();
  const rels = wraps.map((w) => w.dataset.rel).filter(Boolean);
  wraps.forEach((w) => w.remove());
  const tray = attachTray();
  if (tray) tray.classList.remove("show");
  return rels;
}

async function sendMessage(overrideText) {
  if (!state.taskId) return;
  if (isCurrentTaskStreaming()) return;
  // 渠道镜像 task(微信 / 企业微信)只读:web 发的消息推不回去(单向),挡在入口避免不一致(见 applyChannelComposerLock)
  const _chCfg = state.taskMeta && channelCfg(state.taskMeta.channel);
  if (_chCfg) {
    $("chat-hint").textContent = `${_chCfg.label}对话请在${_chCfg.label}里进行 — web 端为只读镜像`;
    return;
  }
  const fromInput = typeof overrideText !== "string";
  let content = (fromInput ? $("chat-input").value : overrideText).trim();
  // 粘贴附件路径注入正文:用户贴图后发的消息往往是「按这张改 / 看看这张图」,
  // 模型只有拿到路径才能传给 seedream(reference_images)/ 未来的 look_at_image。
  const pastedRels = fromInput ? takePastedRels() : [];
  if (pastedRels.length) {
    const lines = pastedRels.map((r) => `[用户上传的参考图] ${r}`).join("\n");
    content = content ? `${content}\n\n${lines}` : lines;
  }
  if (!content) return;
  setActionMode("cancelling");  // 临时锁住,等 events_url 拿到再切 streaming
  $("chat-hint").textContent = "发送中…";
  const taskId = state.taskId;
  try {
    // 立刻渲染 user 消息卡(乐观)
    const wrap = $("chat-stream");
    const userCard = document.createElement("div");
    userCard.className = "msg user";
    userCard.innerHTML = `<div class="role">我</div><div class="body">${escapeHtml(content)}</div>`;
    wrap.appendChild(userCard);

    // assistant 流式占位卡
    const asstCard = document.createElement("div");
    asstCard.className = "msg assistant live-run";
    asstCard.innerHTML = `<div class="role">助手</div><div class="body streaming"></div>`;
    wrap.appendChild(asstCard);
    wrap.scrollTop = wrap.scrollHeight;

    const r = await postMessageWithRetry(taskId, {
      content,
      image_model: state.imageModel || "",
      video_model: state.videoModel || "",
    });
    if (fromInput) $("chat-input").value = "";
    syncOptimizeBtn();
    const run = {
      taskId,
      url: r.events_url,
      acc: "",
      seenRels: new Set(),
      terminal: false,
      card: asstCard,
      curSeg: null,
      cancelling: false,
      workingDir: state.taskMeta && state.taskMeta.working_dir,
      progressSteps: cloneProgressSteps(state.taskProgressByTask.get(taskId)),
    };
    // 预建的空占位 .body 即首个文字段(首字到达前显示「思考中」)
    run.curSeg = { el: asstCard.querySelector(".body"), acc: "", pending: false };
    setRunPhase(run, "llm");  // POST 返回即起跳:覆盖 build_agent + 首轮 TTFT 的空窗
    state.liveRuns.set(taskId, run);
    state.streaming = true;
    syncTaskRowRunIndicator(taskId);
    setActionMode("streaming");
    streamSse(r.events_url, run);
  } catch (e) {
    if (e.status === 401) { logout(); return; }
    // 重试耗尽仍是 503 / 网络拒连 → 部署窗口比重试预算还长,给友好提示让用户稍后重发
    const msg = (e.status === 503 || e.name === "TypeError")
      ? "服务更新中,请稍后重发"
      : e.message;
    appendErrorCard(msg);
    setActionMode("idle");
    $("chat-hint").textContent = "就绪";
  }
}

async function cancelCurrentTask() {
  const run = getLiveRun(state.taskId);
  if (!state.taskId || !run) return;
  run.cancelling = true;
  setActionMode("cancelling");
  syncTaskRowRunIndicator(state.taskId);
  $("chat-hint").textContent = "停止中…";
  try {
    await api("POST", `/v1/tasks/${state.taskId}/cancel`);
    // 不重置 streaming / 按钮 — 等 SSE 的 cancelled / done 走完一并清
  } catch (e) {
    if (e.status === 401) { logout(); return; }
    // 409 = 已结束 / 已 cancelling,不算错;其他贴 toast
    if (e.status !== 409) appendErrorCard("cancel: " + e.message);
    run.cancelling = false;
    setActionMode("streaming");
    syncTaskRowRunIndicator(run.taskId);
    $("chat-hint").textContent = "接收中…";
  }
}

function streamSse(url, run) {
  // EventSource 不支持自定义 header,token 走 query string(?token=...)
  // 这里 SSE 走 same-origin,token 经 URL 传给后端 — 但当前后端只读 Authorization 头
  // 简单做法:走带 token 的 fetch + ReadableStream 替代 EventSource
  fetchSse(url, run).catch((e) => {
    appendRunError(run, "sse: " + e.message);
  });
}

function appendRunError(run, msg) {
  if (state.taskId === run.taskId) {
    appendErrorCard(msg);
    return;
  }
  const host = run.card || createLiveAssistantCard(run);
  const card = document.createElement("div");
  card.className = "msg error";
  card.innerHTML = `<div class="role">错误</div><div class="body">${escapeHtml(msg)}</div>`;
  host.appendChild(card);
}

async function fetchSse(url, run) {
  const ctx = run;
  const hint = $("chat-hint");
  // 重连:reader 异常 / 自然 EOF 但未收到 done/error 时,GET events 重订阅。
  // 后端 stream_events 重连入口会校验 run_status,task 已不活跃直接吐 done 关流;
  // 这里 3 次失败再放弃,覆盖 systemctl restart 的 1~2s 抖动 + reaper 跑完的窗口。
  const backoffs = [1000, 2000, 4000];
  let attempt = 0;
  try {
    while (true) {
      try {
        await consumeSseStream(url, run.card, ctx);
      } catch (e) {
        if (ctx.terminal) break;  // 已收到 done/error,不重连
        if (attempt >= backoffs.length) {
          appendRunError(ctx, "连接已断开,刚才的回复可能未完成,请重发消息。");
          break;
        }
        setRunHint(ctx, `连接断开,重连中…(${attempt + 1}/${backoffs.length})`);
        await new Promise(r => setTimeout(r, backoffs[attempt]));
        attempt++;
        continue;
      }
      // consumeSseStream 正常返回:reader 收到 EOF
      if (ctx.terminal) break;  // 正常收尾(看到 done/error)
      // 未见 done/error 就 EOF → 服务端中途关流(进程被杀 / nginx 切),重连
      if (attempt >= backoffs.length) {
        appendRunError(ctx, "连接已断开,刚才的回复可能未完成,请重发消息。");
        break;
      }
      setRunHint(ctx, `连接断开,重连中…(${attempt + 1}/${backoffs.length})`);
      await new Promise(r => setTimeout(r, backoffs[attempt]));
      attempt++;
    }
    // 最终定稿 + 代码高亮(流式中不 highlight,省 CPU):定稿当前打开的文字段,
    // 已被工具关闭的历史段在 closeTextSeg 时已定稿 + 高亮。
    closeReasonSeg(ctx);  // 仍开着的思考过程段定稿折叠(如推理中被 cancel)
    if (ctx.curSeg && ctx.curSeg.el) {
      if (ctx.curSeg.acc) ctx.curSeg.el.innerHTML = renderMd(ctx.curSeg.acc);
      else ctx.curSeg.el.remove();  // 收尾时仍是空占位段 → 移除
    }
    if (ctx.card) highlightIn(ctx.card);
  } finally {
    finishRunningTool(ctx);  // 工具执行中被杀/断流 → 去 spinner
    if (ctx.card) {
      ctx.card.querySelectorAll(".body.streaming").forEach((b) => b.classList.remove("streaming"));
      ctx.card.querySelectorAll(".tool-call.running").forEach((d) => d.classList.remove("running"));
      ctx.card.querySelectorAll("details.reasoning.streaming").forEach((d) => { d.classList.remove("streaming"); d.open = false; });
    }
    state.liveRuns.delete(ctx.taskId);
    state.streaming = state.liveRuns.size > 0;
    syncTaskRowRunIndicator(ctx.taskId);  // 先按本地态即时清标识,loadTaskList 随后带回服务端真相(如 error)
    if (state.taskId === ctx.taskId) {
      hint.textContent = ctx.lastUsageHint || "就绪";
      setActionMode("idle");
    }
  }
  // 刷新 task meta + messages(拿真实持久化的);失败路径已退出,这里不再跑
  loadTaskList();
  if (state.taskId === ctx.taskId) {
    // 重新拉 meta:SSE 期间 token/缓存命中/花费只累计进 hint,未落 state.taskMeta,
    // 顶栏计量栏会停在发问前的旧值,直到切走再切回才更新 — 这里补一次让它即时刷新。
    try {
      const meta = await api("GET", "/v1/tasks/" + ctx.taskId);
      if (state.taskId === ctx.taskId) { state.taskMeta = meta; renderChatMeta(); }
    } catch (e) { /* meta 刷新失败不致命:保留旧值,不打断收尾 */ }
    await loadMessages();
    refreshOutline();  // 本轮新增 user 提问 → 目录补一条
    loadFiles();  // 回复结束后右侧文件面板同步刷新(可能有新写入 / 修改的产物)
    refreshConcurrentWarnings();  // 自己 task 收尾,顺便清/更新 banner(同 wd 邻居可能也变了)
  }
}

async function consumeSseStream(url, asstCard, ctx) {
  const r = await fetch(url, {
    headers: { "Authorization": "Bearer " + state.token, "Accept": "text/event-stream" },
  });
  if (!r.ok) throw new Error(r.status + " " + r.statusText);
  const reader = r.body.getReader();
  const dec = new TextDecoder();
  let buf = "";
  setRunHint(ctx, "接收中…");
  while (true) {
    const { value, done } = await reader.read();
    if (done) return;
    buf += dec.decode(value, { stream: true });
    while (true) {
      const idx = buf.indexOf("\n\n");
      if (idx < 0) break;
      const frame = buf.slice(0, idx);
      buf = buf.slice(idx + 2);
      const ev = parseSseFrame(frame);
      if (!ev) continue;
      handleSseEvent(ev, asstCard, ctx);
      if (ev.event === "done" || ev.event === "error") {
        ctx.terminal = true;
        refreshProcs();  // run 收尾顺手校正后台进程条(run 中可能起了/杀了 proc)
        return;
      }
    }
  }
}

function parseSseFrame(frame) {
  const lines = frame.split("\n");
  let event = "msg"; let dataLines = [];
  for (const ln of lines) {
    if (ln.startsWith(":")) continue;  // comment
    if (ln.startsWith("event:")) event = ln.slice(6).trim();
    else if (ln.startsWith("data:")) dataLines.push(ln.slice(5).replace(/^ /, ""));
  }
  if (!dataLines.length) return { event, data: null };
  const raw = dataLines.join("\n");
  let data = null;
  try { data = JSON.parse(raw); } catch (e) { data = raw; }
  return { event, data };
}

// ask_user 选项卡:question + 一组可点击选项。点击走 chat-stream 的事件委托(把选项
// label 当作新用户消息发出);interactive=false(历史里已答)时按钮置灰、命中项标「已选」。
// 返回 DOM 元素(实时流式直接 append);历史重渲取其 outerHTML 注入(点击仍由委托接住)。
function buildAskUserCard(args, opts) {
  const o = opts || {};
  const interactive = o.interactive !== false;
  const chosen = o.chosenLabel || "";
  const question = (args && args.question) || "";
  const options = (args && Array.isArray(args.options)) ? args.options : [];
  const card = document.createElement("div");
  card.className = "ask-user" + (interactive ? "" : " answered");
  const btns = options.map((op) => {
    const label = (op && op.label) || "";
    const desc = (op && op.description) || "";
    const sel = (!interactive && chosen && chosen === label) ? " selected" : "";
    const dis = interactive ? "" : " disabled";
    return `<button type="button" class="ask-option${sel}" data-label="${escapeHtml(label)}"${dis}>
      <span class="ask-option-label">${escapeHtml(label)}</span>
      ${desc ? `<span class="ask-option-desc">${escapeHtml(desc)}</span>` : ""}
    </button>`;
  }).join("");
  card.innerHTML = `
    <div class="ask-q">${escapeHtml(question)}</div>
    <div class="ask-options">${btns}</div>
    <div class="ask-hint">${interactive ? "点选项继续,或在下方输入框就此讨论" : "已选择"}</div>`;
  return card;
}

function handleSseEvent(ev, asstCard, ctx) {
  const t = ev.event;
  asstCard = asstCard || ctx.card || createLiveAssistantCard(ctx);
  ctx.card = asstCard;
  const stream = $("chat-stream");
  const visible = state.taskId === ctx.taskId;
  // 用户拖到上面看历史时不抢滚动,只在贴底时跟流
  const nearBottom = visible && (stream.scrollHeight - stream.scrollTop - stream.clientHeight < 120);
  if (t === "llm_start") {
    ctx.contextStats = ev.data || {};
    setRunHint(ctx, formatContextStats(ctx.contextStats));
    // 每轮 LLM 起点带最新窗口体量 + 容量 → 实时刷压缩指示环
    const cs = ctx.contextStats;
    if (visible && state.taskMeta && cs.context_limit_chars) {
      state.taskMeta.context_window_chars = cs.context_original_chars || 0;
      state.taskMeta.context_limit_chars = cs.context_limit_chars;
      state.taskMeta.context_pressure =
        (cs.context_original_chars || 0) / cs.context_limit_chars;
      refreshCtxRing();
    }
    // 每轮 LLM 起点重建空占位段(工具轮之后 curSeg 已被关):TTFT 期间显示
    // 「思考中 · Ns」,而不是页面静止
    ensureTextSeg(ctx);
    setRunPhase(ctx, "llm");
  } else if (t === "llm_end") {
    ctx.lastUsageHint = formatUsageStats(ev.data || {}, ctx.contextStats);
    setRunHint(ctx, ctx.lastUsageHint);
  } else if (t === "reasoning" && ev.data && ev.data.delta) {
    if (ctx.phase !== "reasoning") setRunPhase(ctx, "reasoning");
    const rs = ensureReasonSeg(ctx);
    rs.acc += ev.data.delta;
    if (!rs.pending) {
      rs.pending = true;
      requestAnimationFrame(() => {
        rs.body.textContent = rs.acc;
        rs.body.scrollTop = rs.body.scrollHeight;  // 内滚容器跟到最新一行
        rs.pending = false;
        if (nearBottom) stream.scrollTop = stream.scrollHeight;
      });
    }
  } else if (t === "text" && ev.data && ev.data.delta) {
    closeReasonSeg(ctx);  // 正文开始 → 思考过程定稿折叠
    ctx.acc += ev.data.delta;
    const seg = ensureTextSeg(ctx);  // 无打开文字段则在卡片底部另起一段
    seg.acc += ev.data.delta;
    // rAF 节流:每帧最多 1 次重渲染,流式 token 高频时不抖。闭包捕获 seg —— 若 rAF
    // 触发前来了工具调用把当前段关掉,仍渲染这一段自己的累积文本,不会错渲到别的段。
    if (!seg.pending) {
      seg.pending = true;
      requestAnimationFrame(() => {
        seg.el.innerHTML = renderMd(seg.acc);
        seg.pending = false;
        if (nearBottom) stream.scrollTop = stream.scrollHeight;
      });
    }
  } else if (t === "tool_call") {
    const fn = (ev.data && ev.data.name) || "?";
    const args = (ev.data && ev.data.args) || "";
    const argsStr = typeof args === "string" ? args : JSON.stringify(args, null, 2);
    if (fn === "task_progress") {
      ctx.progressSteps = applyProgressAction(ctx.progressSteps || [], args);
      setTaskProgress(ctx.taskId, ctx.progressSteps);
      // dock 是 chat-stream 上方的 flex 兄弟:它一涨,chat-stream 可视高度就缩,
      // 原本贴底的内容被挤到视口下方看不见。进度是隐形动作会早 return 跳过末尾的
      // 触底兜底,所以这里补一次:贴底时重新钉到底,别让 dock 展开遮住最新内容。
      if (nearBottom) stream.scrollTop = stream.scrollHeight;
      return;  // 进度是隐形动作,不落可见卡 → 不打断当前文字段
    }
    closeReasonSeg(ctx);  // 思考过程随文字段一起定稿
    closeTextSeg(ctx);  // 关闭当前文字段:工具/选项卡追加到其下方,之后新文字另起底部段
    if (fn === "ask_user") {
      asstCard.appendChild(buildAskUserCard(args, { interactive: true }));
      if (nearBottom) stream.scrollTop = stream.scrollHeight;
      return;
    }
    const label = toolActivityLabel(fn, args);
    const det = document.createElement("details");
    det.className = "tool-call running";
    det.innerHTML = `<summary>${escapeHtml(label)}<span class="tool-elapsed"></span></summary><pre>${escapeHtml(argsStr)}</pre>`;
    asstCard.appendChild(det);
    // 运行态:spinner + 跳秒,tool_result 到达时定格(区分"在执行"和"卡死")
    ctx.runningToolEl = det;
    ctx.toolElapsedEl = det.querySelector(".tool-elapsed");
    setRunPhase(ctx, "tool");
    const wd = _workingDirName(ctx.workingDir);
    const isProducer = ARTIFACT_PRODUCING_TOOLS.has(fn);
    const fresh = isProducer
      ? extractArtifactRels(argsStr, wd).filter(r => !ctx.seenRels.has(r))
      : [];
    fresh.forEach(r => ctx.seenRels.add(r));
    const barHtml = renderArtifactBarHtml(fresh, isProducer);
    if (barHtml) {
      asstCard.insertAdjacentHTML("beforeend", barHtml);
      if (isProducer) upgradeMediaArtifacts(asstCard);
    }
  } else if (t === "tool_result") {
    const txt = (ev.data && ev.data.result) || "";
    const txtStr = typeof txt === "string" ? txt : JSON.stringify(txt, null, 2);
    const toolName = (ev.data && ev.data.name) || "";
    if (toolName === "task_progress") return;
    if (toolName === "ask_user") return;  // 选项卡已在 tool_call 阶段渲染,结果是占位不展示
    finishRunningTool(ctx);  // 去 spinner,elapsed 定格
    closeTextSeg(ctx);  // 结果卡追加到当前文字段之下,之后新文字另起底部段
    const banner = extractMediaBanner(toolName, txtStr);
    const det = document.createElement("details");
    det.className = "tool-call";
    det.innerHTML = `<summary>工具结果${banner}</summary><pre>${escapeHtml(txtStr)}</pre>`;
    asstCard.appendChild(det);
    const wd = _workingDirName(ctx.workingDir);
    const isProducer = ARTIFACT_PRODUCING_TOOLS.has(toolName);
    const fresh = isProducer
      ? extractArtifactRels(txtStr, wd).filter(r => !ctx.seenRels.has(r))
      : [];
    fresh.forEach(r => ctx.seenRels.add(r));
    const barHtml = renderArtifactBarHtml(fresh, isProducer);
    if (barHtml) {
      asstCard.insertAdjacentHTML("beforeend", barHtml);
      if (isProducer) upgradeMediaArtifacts(asstCard);
    }
    if (visible) scheduleFilesRefresh();  // 工具调用结果回来,FS 可能被改了,debounce 刷新右侧
    // bg proc 刚启动([Background] 开头的结果)→ 结果卡活化(spinner/跳秒/停止)
    // + 立刻拉一次状态,秒数即时起跳
    if (txtStr.startsWith("[Background]")) {
      decorateBgprocCard(det, txtStr);
      refreshProcs();
    }
  } else if (t === "context_fold") {
    // 长会话中段折叠(§8.8 Phase 2):start 阶段在状态行提示(摘要调用要几秒,别静止),
    // done 阶段插一条与 model_switch 同款的分隔线,让"上下文被整理过"在流里可见。
    const d = ev.data || {};
    if (d.phase === "start") {
      setRunHint(ctx, `整理长对话上下文中…(折叠 ${d.folding_messages || 0} 条早期消息)`);
    } else if (d.phase === "done") {
      closeTextSeg(ctx);
      const sep = document.createElement("div");
      sep.className = "context-fold muted";
      sep.style.cssText = "margin:8px 0;text-align:center;font-size:11px;letter-spacing:0.5px;";
      sep.textContent = `── 已整理长对话:${d.folded_messages || 0} 条早期消息折叠为摘要`
        + `(ctx ${fmtTokens(d.before_chars || 0)}→${fmtTokens(d.after_chars || 0)} chars)──`;
      asstCard.appendChild(sep);
      // 折叠计数 + 折叠后体量同步到指示环(limit 不变,窗口骤降 → 环回绿)
      if (visible && state.taskMeta) {
        state.taskMeta.context_folds = (state.taskMeta.context_folds || 0) + 1;
        if (d.after_chars && state.taskMeta.context_limit_chars) {
          state.taskMeta.context_window_chars = d.after_chars;
          state.taskMeta.context_pressure =
            d.after_chars / state.taskMeta.context_limit_chars;
        }
        refreshCtxRing();
      }
    }
  } else if (t === "model_switch") {
    // skill 定向模型热切(load_skill 后):流里插一条与历史重渲同款的模型分隔线,
    // 并同步顶栏下拉 + taskMeta(后端已持久化 tasks.model_profile,刷新后一致)。
    const prof = (ev.data && ev.data.model_profile) || "";
    if (prof) {
      const dn = (state.models.find(x => x.profile === prof) || {}).display_name || prof;
      closeTextSeg(ctx);
      const sep = document.createElement("div");
      sep.className = "model-switch muted";
      sep.style.cssText = "margin:8px 0;text-align:center;font-size:11px;letter-spacing:0.5px;";
      sep.textContent = `── ${dn}（skill 指定）──`;
      asstCard.appendChild(sep);
      if (visible && state.taskMeta) {
        state.taskMeta.model_profile = prof;
        const sel = $("chat-model-sel");
        if (sel) {
          // 档外定向模型不在该用户的下拉清单里 → 临时补一项,保持显示真实
          if (![...sel.options].some(o => o.value === prof)) {
            const o = document.createElement("option");
            o.value = prof; o.textContent = dn;
            sel.appendChild(o);
          }
          sel.value = prof;
        }
        $("chat-hint").textContent = `skill 指定模型 → ${dn}`;
      }
    }
  } else if (t === "warn") {
    // loop 的运行时提醒(无进展熔断/重复拦截/折叠失败等)。此前被静默丢弃,与"停下
    // 不静默"的意图相悖 —— 渲染成流内 muted 单行,不打断当前文字段的排版节奏。
    const msg = (ev.data && ev.data.msg) || "";
    if (msg) {
      closeTextSeg(ctx);
      const w = document.createElement("div");
      w.className = "run-warn muted";
      w.style.cssText = "margin:6px 0;font-size:12px;color:var(--warn,#b8860b);";
      w.textContent = `⚠ ${msg}`;
      asstCard.appendChild(w);
    }
  } else if (t === "cancelled") {
    // 「已停止」不在此 live-append —— 会被紧随的收尾 loadMessages 整屏重建冲掉(旧行为:
    // 闪一下即消失)。后端已把 run_status 落成持久 cancelled,收尾重渲时
    // renderPersistedRunTerminal 据此补居中「已停止」条,刷新/切任务后仍在。
  } else if (t === "error") {
    const msg = (ev.data && (ev.data.msg || ev.data.error)) || JSON.stringify(ev.data);
    appendRunError(ctx, msg);
  }
  if (nearBottom) stream.scrollTop = stream.scrollHeight;
}

// 持久终态卡:上次 run 以 error(tasks.run_error)或 cancelled(用户停止)收场
// → 消息流末尾补一张卡。live 追加的卡(错误卡 / 「已停止」badge)会被收尾 loadMessages
// 的整屏重建冲掉(renderMessages 先 innerHTML="",非持久化消息重建即消失 —— 2a1bc25d
// 案"发了继续啥也没发生"的根因);这张以 meta.run_status 为准的卡在每次重渲后补,
// 刷新/切换任务也在。下次 run 起跑时后端把 run_status 写回 running,meta 刷新后自然消失。
function renderPersistedRunTerminal() {
  document.querySelectorAll("#chat-stream .run-error-persist, #chat-stream .run-cancelled-persist")
    .forEach((el) => el.remove());
  const t = state.taskMeta;
  if (!t || state.taskId !== t.task_id || getLiveRun(state.taskId)) return;
  if (t.run_status === "error") {
    const card = document.createElement("div");
    card.className = "msg error run-error-persist";
    card.innerHTML = `<div class="role">上次运行出错</div><div class="body">${escapeHtml(t.run_error || "(未知错误)")}`
      + `<div class="muted" style="margin-top:4px;font-size:12px;">可直接重发消息或回复「继续」重试。</div></div>`;
    $("chat-stream").appendChild(card);
  } else if (t.run_status === "cancelled") {
    // 用户主动停止 —— 良性终态,渲成居中 muted 虚线小条(同 model-switch 分隔风格),
    // 不用错误卡那种醒目样式。
    const card = document.createElement("div");
    card.className = "run-cancelled-persist muted";
    card.textContent = "已停止 · 你中断了上次运行";
    card.style.cssText = "align-self:center;margin:8px 0;padding:4px 12px;font-size:12px;"
      + "border:1px dashed var(--border);border-radius:var(--r-md);";
    $("chat-stream").appendChild(card);
  }
}

function appendErrorCard(msg) {
  const card = document.createElement("div");
  card.className = "msg error";
  card.innerHTML = `<div class="role">错误</div><div class="body">${escapeHtml(msg)}</div>`;
  $("chat-stream").appendChild(card);
  $("chat-stream").scrollTop = $("chat-stream").scrollHeight;
}

// ───── done / abandon / delete / export ─────
$("btn-done").onclick = () => state.taskId && setTaskStatus(state.taskId, "completed", (state.taskMeta && state.taskMeta.name) || "");
// 其余操作(废弃/导出/清空/删除)走与任务行同款 ⋯ 浮层菜单;「完成」已是独立按钮 → 菜单里去掉
$("btn-task-menu").onclick = (e) => {
  if (!state.taskId || !state.taskMeta) return;
  e.stopPropagation();
  showMenu($("btn-task-menu"), taskMenuItems(state.taskMeta).filter((it) => it.act !== "complete"));
};

async function clearMessages(tid, name, nMsg) {
  if (!await dialogConfirm({
    title: "清空对话",
    message: `确认清空「${name}」的对话(${nMsg} 条消息)?\n\n将删除全部对话历史并重置 token 计数;工作目录下的文件保留。`,
    okText: "清空", danger: true,
  })) return;
  try {
    const updated = await api("POST", "/v1/tasks/" + tid + "/clear");
    if (state.taskId === tid) {
      // clear 返回不含 context_* 字段;全删后窗口/折叠归零,指示环回空
      state.taskMeta = {
        ...state.taskMeta, ...updated,
        context_window_chars: 0, context_pressure: 0, context_folds: 0,
      };
      renderChatMeta();
      renderMessages([]);
      state.outline = []; renderOutlineRail();  // 对话清空 → 右侧导航条(目录圆点)同步清空
      $("chat-hint").textContent = "对话已清空";
    }
    loadTaskList();
  } catch (e) {
    if (e.status === 401) { logout(); return; }
    message("清空失败:" + e.message, "error");
  }
}

async function renameTask(tid, curName) {
  // 只改 tasks.name(显示名),不动 working_dir/磁盘产物 —— 定位主锚是 task_id/short_id。
  // 后端 PATCH 带 validate_task_name 校验;成功后前端各处(列表/标题栏)重渲染即同步。
  const next = await dialogPrompt({
    title: "重命名任务", label: "任务名称", value: curName || "", placeholder: "输入新的任务名",
  });
  if (next == null) return;
  const trimmed = next.trim();
  if (!trimmed || trimmed === curName) return;
  try {
    const updated = await api("PATCH", "/v1/tasks/" + tid, { name: trimmed });
    if (state.tasksById[tid]) state.tasksById[tid].name = updated.name;
    if (state.taskId === tid && state.taskMeta) {
      state.taskMeta = { ...state.taskMeta, name: updated.name };
      renderChatMeta();  // 顶部标题栏立即刷新
    }
    loadTaskList();  // 左栏列表重拉,行内名字同步
  } catch (e) {
    if (e.status === 401) { logout(); return; }
    message("重命名失败:" + e.message, "error");
  }
}

async function setTaskStatus(tid, status, name) {
  const labels = { completed: "已完成", abandoned: "已废弃" };
  if (!await dialogConfirm({
    title: "更改任务状态",
    message: `确认将「${name || tid.slice(0,8)}」置为「${labels[status] || status}」?`,
  })) return;
  try {
    await api("PATCH", "/v1/tasks/" + tid, { status });
    if (state.taskId === tid) await selectTask(tid);
    loadTaskList();
  } catch (e) {
    if (e.status === 401) { logout(); return; }
    message("操作失败:" + e.message, "error");
  }
}

async function deleteTask(tid, name, nMsg) {
  if (!await dialogConfirm({
    title: "删除任务",
    message: `确认硬删除任务「${name}」(${nMsg} 条消息)?\n\n将清掉对话历史和 DB 行,但工作目录下的文件保留。`,
    okText: "删除", danger: true,
  })) return;
  try {
    await api("DELETE", "/v1/tasks/" + tid);
    if (state.taskId === tid) {
      if (state.evtSrc) { state.evtSrc.close(); state.evtSrc = null; }
      state.taskId = null;
      state.taskMeta = null;
      state.concurrentWarnings = [];
      renderConcurrentWarning();
      $("chat-meta").innerHTML = `<span class="muted">(未选中任务)</span>`;
      $("chat-stream").innerHTML = `<div class="empty">请在左侧选一个任务</div>`;
      renderTaskProgressDock([]);
      $("chat-form").style.display = "none";
      $("btn-done").disabled = true;
      $("btn-task-menu").disabled = true;
    }
    loadTaskList();
    loadFiles();
  } catch (e) {
    if (e.status === 401) { logout(); return; }
    message("删除失败:" + e.message, "error");
  }
}

function exportTask(tid) {
  fetch("/v1/tasks/" + tid + "/export", {
    headers: { "Authorization": "Bearer " + state.token },
  }).then(async (r) => {
    if (!r.ok) { message("导出失败:" + r.status, "error"); return; }
    const blob = await r.blob();
    const a = document.createElement("a");
    a.href = URL.createObjectURL(blob);
    a.download = "chat_" + tid.slice(0, 8) + ".docx";
    document.body.appendChild(a); a.click();
    setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
  });
}
