// === CC-Web Frontend ===
(function () {
  'use strict';

  const ASSET_VERSION = '20260702-visible-no-rerender';
  const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`;
  const RENDER_DEBOUNCE = 100;
  const COMPOSER_SUGGESTION_DEBOUNCE = 120;
  const DIVIDER_TIME_STORAGE_KEY = 'cc-web-show-divider-time';
  const CCWEB_PROMPT_VIEW_MODE_STORAGE_KEY = 'cc-web-ccweb-prompt-view-mode';
  const PROJECT_COLLAPSE_STORAGE_KEY = 'cc-web-collapsed-projects';
  const CROSS_CONVERSATION_REPLY_COLLAPSE_STORAGE_KEY = 'cc-web-collapsed-cross-replies';
  const CROSS_CONVERSATION_REPLY_COLLAPSE_LIMIT = 500;
  const ASSISTANT_LAST_SECTION_BUTTON_CLASS = 'msg-last-section-btn';
  const ASSISTANT_BRANCH_BUTTON_CLASS = 'msg-branch-btn';
  const ASSISTANT_LAST_SECTION_FOCUS_CLASS = 'msg-last-section-focus';
  const ASSISTANT_LAST_SECTION_SCROLL_OFFSET = 72;
  const ASSISTANT_LAST_SECTION_SKIP_SELECTOR = [
    `.${ASSISTANT_LAST_SECTION_BUTTON_CLASS}`,
    `.${ASSISTANT_BRANCH_BUTTON_CLASS}`,
    '.msg-action-row',
    '.msg-tools',
    '.tool-call',
    '.tool-group',
    '.msg-attachments',
    '.msg-attachment-card',
    '.cross-conversation-meta',
    '.agent-message-divider',
  ].join(',');
  const ASSISTANT_LAST_SECTION_SCOPE_SELECTOR = [
    'p',
    'li',
    'h1',
    'h2',
    'h3',
    'h4',
    'h5',
    'h6',
    'td',
    'th',
    'pre',
    'code',
    '.msg-text',
  ].join(',');

  const SLASH_COMMANDS = [
    { cmd: '/clear', desc: '清除当前会话' },
    { cmd: '/model', desc: '查看/切换模型' },
    { cmd: '/mode', desc: '查看/切换权限模式' },
    { cmd: '/cost', desc: '查看会话费用' },
    { cmd: '/compact', desc: '压缩上下文' },
    { cmd: '/goal', desc: '设置/查看 Codex App 持久目标' },
    { cmd: '/init', desc: '生成/更新 Agent 指南文件' },
    { cmd: '/help', desc: '显示帮助' },
  ];

  const MODE_LABELS = {
    default: '默认',
    plan: 'Plan',
    yolo: 'YOLO',
  };

  const AGENT_LABELS = {
    claude: 'Claude',
    codex: 'Codex',
    codexapp: 'Codex App',
  };

  const PRIMARY_UI_AGENT = 'codexapp';
  const DEFAULT_AGENT = PRIMARY_UI_AGENT;
  const SESSION_CACHE_LIMIT = 4;
  const SESSION_CACHE_MAX_WEIGHT = 1_500_000;
  const SIDEBAR_SWIPE_TRIGGER = 72;
  const SIDEBAR_SWIPE_MAX_VERTICAL_DRIFT = 42;
  const OLD_SESSION_GROUP_INITIAL_VISIBLE = 3;
  const SESSION_GROUP_COMPACT_VISIBLE_LIMIT = 8;
  const OLD_SESSION_COLLAPSE_DAYS = 7;
  const OLD_SESSION_COLLAPSE_MS = OLD_SESSION_COLLAPSE_DAYS * 24 * 60 * 60 * 1000;
  const SESSION_LOAD_OVERLAY_TIMEOUT_MS = 12_000;
  const SESSION_LOAD_REQUEST_TIMEOUT_MS = 45_000;
  const SESSION_RESUME_FALLBACK_MS = 1_500;
  const PENDING_SLASH_DRAFT_LIMIT = 30;

  const MODEL_OPTIONS = [
    { value: 'opus', label: 'Opus', desc: '最强大，1M 上下文' },
    { value: 'sonnet', label: 'Sonnet', desc: '平衡性能，1M 上下文' },
    { value: 'haiku', label: 'Haiku', desc: '最快速，适合简单任务' },
  ];

	  const DEFAULT_CODEX_MODEL_OPTIONS = [
	    { value: 'gpt-5.4', label: 'GPT-5.4', desc: '当前主力 Codex 模型' },
	    { value: 'gpt-5.3-codex', label: 'GPT-5.3 Codex', desc: '偏工程执行场景' },
	    { value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex', desc: '兼容旧路由与旧配置' },
	    { value: 'gpt-5.2', label: 'GPT-5.2', desc: '通用 OpenAI 兼容模型' },
	  ];

  const MODE_PICKER_OPTIONS = [
    { value: 'yolo', label: 'YOLO', desc: '跳过所有权限检查' },
    { value: 'plan', label: 'Plan', desc: '执行前需确认计划' },
    { value: 'default', label: '默认', desc: '标准权限审批' },
  ];

  const THEME_OPTIONS = [
    {
      value: 'washi',
      label: 'Washi Warm',
      desc: '暖纸色与朱砂点缀，保留当前熟悉的 CC-Web 气质。',
      swatches: ['#faf6f0', '#f2ebe2', '#c0553a', '#5d8a54'],
    },
    {
      value: 'coolvibe',
      label: 'CoolVibe Light',
      desc: '保留 CoolVibe 的青色科技感，但改成更干净的浅色工作台。',
      swatches: ['#f7fbfc', '#eef7f9', '#0891b2', '#ffffff'],
    },
    {
      value: 'editorial',
      label: 'Editorial Sand',
      desc: '更明亮的留白和更克制的棕色强调，像编辑台一样安静。',
      swatches: ['#f6f1e8', '#efe8dc', '#8b5e3c', '#2f4b45'],
    },
    {
      value: 'sage',
      label: 'Sage Console',
      desc: '清透鼠尾草绿与石墨文字，适合长时间工作流。',
      swatches: ['#f5f8f2', '#e6efdf', '#2f6f64', '#557ba3'],
    },
    {
      value: 'ink',
      label: 'Ink Focus',
      desc: '浅灰纸面配靛蓝重点，信息密度更高也更冷静。',
      swatches: ['#f6f7fb', '#e8edf5', '#3f5fb5', '#3f8f73'],
    },
    {
      value: 'dawn',
      label: 'Dawn Studio',
      desc: '晨光米白配珊瑚红，保留温度但比暖纸更轻。',
      swatches: ['#fff7f2', '#f3e8e1', '#b5524d', '#4f8a6b'],
    },
    {
      value: 'carbon',
      label: 'Carbon Mint',
      desc: '石墨黑底配薄荷绿重点，夜间使用更稳。',
      swatches: ['#0f1314', '#202829', '#67d8b2', '#9fb7ff'],
    },
    {
      value: 'nocturne',
      label: 'Nocturne Teal',
      desc: '深海青黑配电光蓝，适合高专注会话。',
      swatches: ['#081417', '#142b31', '#5ecdf5', '#f0c36a'],
    },
    {
      value: 'cinder',
      label: 'Cinder Rose',
      desc: '炭黑底配低饱和玫瑰色，暗色里保留一点温度。',
      swatches: ['#151112', '#2a2022', '#e68193', '#67c587'],
    },
  ];

  // --- State ---
  let ws = null;
  let wsAuthenticated = false;
  let authToken = localStorage.getItem('cc-web-token');
  let currentSessionId = null;
  let sessions = [];
  let sessionCache = new Map();
  let isGenerating = false;
  let reconnectAttempts = 0;
  let reconnectTimer = null;
  let isPageUnloading = false;
  let pendingText = '';
  let renderTimer = null;
  let generatingSessionId = null;
  let activeToolCalls = new Map();
  let activeTodoCallTargets = new Map();
  let closedCollabAgentIds = new Set();
  let toolDomSeq = 0;
  let toolGroupCount = 0;   // 当前 .msg-tools 直接子节点数（含已有父目录）
  let hasGrouped = false;  // 本次输出是否已触发过折叠
  let cmdMenuIndex = -1;
  let currentMode = 'yolo';
  let currentModel = '';
  let currentAgent = DEFAULT_AGENT;
  let currentTheme = (document.documentElement.dataset.theme || localStorage.getItem('cc-web-theme') || 'washi');
  let showAgentDividerTime = localStorage.getItem(DIVIDER_TIME_STORAGE_KEY) !== '0';
  let codexConfigCache = null;
  let loadedHistorySessionId = null;
  let activeSessionLoad = null;
  let sessionLoadOverlayTimer = null;
  let sessionLoadRequestTimer = null;
  let sessionResumeFallbackTimer = null;
  let sidebarSwipe = null;
  let activeComposerToken = null;
  let composerSuggestionTimer = null;
  let composerRequestSeq = 0;
  let latestComposerRequestId = '';
  let pendingAttachments = [];
  let uploadingAttachments = [];
  let attachmentPreviewModal = null;
  const attachmentPreviewCache = new Map();
  let loginPasswordValue = ''; // store login password for force-change flow
  let currentCwd = null;
  let currentSessionMessageCount = 0;
  let currentSessionRunning = false;
  let fileBrowserState = null;
  let directoryPickerState = null;
  let codexAppUserInputModal = null;
  let codexAppApprovalModal = null;
  let pendingNewSessionRequest = null;
  let pendingSessionSwitchRequest = null;
  let pendingSessionResumeRequest = null;
  let sessionSwitchRequestSeq = 0;
  let skipDeleteConfirm = localStorage.getItem('cc-web-skip-delete-confirm') === '1';
  let pendingInitialSessionLoad = false;
  let initialSessionListHandled = false;
  let noteMode = false;
  let noteDraftSeq = 0;
  let queuedMessageSeq = 0;
  let queuedMessageDrainTimer = null;
  let isReloadingMcp = false;
  const mcpStartupToastKeys = new Map();
  let sessionSearchQuery = '';
  const collapsedProjectKeys = (() => {
    try {
      const parsed = JSON.parse(localStorage.getItem(PROJECT_COLLAPSE_STORAGE_KEY) || '[]');
      return new Set(Array.isArray(parsed) ? parsed.filter(Boolean) : []);
    } catch {
      return new Set();
    }
  })();
  const collapsedCrossConversationReplyKeys = (() => {
    try {
      const parsed = JSON.parse(localStorage.getItem(CROSS_CONVERSATION_REPLY_COLLAPSE_STORAGE_KEY) || '[]');
      return new Set(Array.isArray(parsed) ? parsed.filter(Boolean) : []);
    } catch {
      return new Set();
    }
  })();
  const pendingNotesByTarget = new Map();
  const queuedMessagesByTarget = new Map();
  const userMessageIndex = new Map();
  const pendingSlashDraftsByRequestId = new Map();
  const expandedOldSessionGroups = new Set();
  document.documentElement.dataset.dividerTime = showAgentDividerTime ? 'show' : 'hide';

  // --- DOM ---
  const $ = (sel) => document.querySelector(sel);
  const loginOverlay = $('#login-overlay');
  const loginForm = $('#login-form');
  const loginPassword = $('#login-password');
  const loginError = $('#login-error');
  const rememberPw = $('#remember-pw');
  const app = $('#app');
  const sessionLoadingOverlay = $('#session-loading-overlay');
  const sessionLoadingLabel = $('#session-loading-label');
  const sidebar = $('#sidebar');
  const sidebarOverlay = $('#sidebar-overlay');
  const menuBtn = $('#menu-btn');
  const chatMain = document.querySelector('.chat-main');
  const newChatSplit = sidebar.querySelector('.new-chat-split');
  const newChatBtn = $('#new-chat-btn');
  const newChatArrow = $('#new-chat-arrow');
  const newChatDropdown = $('#new-chat-dropdown');
  const importSessionBtn = $('#import-session-btn');
  const sessionSearchInput = $('#session-search-input');
  const sessionSearchClear = $('#session-search-clear');
  const sessionList = $('#session-list');
  const chatTitle = $('#chat-title');
  const chatSessionIdBtn = $('#chat-session-id-btn');
  const chatAgentBtn = $('#chat-agent-btn');
  const chatAgentMenu = $('#chat-agent-menu');
  const chatAgentPicker = chatAgentBtn?.closest('.chat-agent-picker') || chatAgentMenu?.closest('.chat-agent-picker') || null;
  const chatRuntimeState = $('#chat-runtime-state');
  const chatCwd = $('#chat-cwd');
  const userOutlineBtn = $('#user-outline-btn');
  const userOutlinePanel = $('#user-outline-panel');
  const ccwebPromptOutlineBtn = $('#ccweb-prompt-outline-btn');
  const ccwebPromptOutlinePanel = $('#ccweb-prompt-outline-panel');
  const reloadMcpBtn = $('#reload-mcp-btn');
  const costDisplay = $('#cost-display');
  const attachmentTray = $('#attachment-tray');
  const pendingNotesTray = $('#pending-notes-tray');
  const imageUploadInput = $('#image-upload-input');
  const attachBtn = $('#attach-btn');
  const messagesDiv = $('#messages');
  const msgInput = $('#msg-input');
  const inputWrapper = msgInput.closest('.input-wrapper');
  const noteModeBtn = $('#note-mode-btn');
  const queueSendBtn = $('#queue-send-btn');
  const sendBtn = $('#send-btn');
  const abortBtn = $('#abort-btn');
  const cmdMenu = $('#cmd-menu');
  const modeSelect = $('#mode-select');
  const defaultMsgInputPlaceholder = msgInput.getAttribute('placeholder') || '输入消息… 输入 / 查看指令';

  // --- Viewport height fix for mobile browsers ---
  function setVH() {
    document.documentElement.style.setProperty('--vh', `${window.innerHeight * 0.01}px`);
  }
  setVH();
  window.addEventListener('resize', setVH);
  window.addEventListener('orientationchange', () => setTimeout(setVH, 100));

  function buildWelcomeMarkup(agent) {
    const label = AGENT_LABELS[agent] || AGENT_LABELS.claude;
    return `<div class="welcome-msg"><div class="welcome-icon">✿</div><h3>欢迎使用 CC-Web</h3><p>开始与 ${label} 对话</p></div>`;
  }

  function normalizeAgent(agent) {
    return AGENT_LABELS[agent] ? agent : DEFAULT_AGENT;
  }

  function normalizeUiAgent() {
    return PRIMARY_UI_AGENT;
  }

  function isPrimaryUiAgent(agent) {
    return normalizeAgent(agent) === PRIMARY_UI_AGENT;
  }

  function isCodexLikeAgent(agent) {
    const normalized = normalizeAgent(agent);
    return normalized === 'codex' || normalized === 'codexapp';
  }

  function isCodexAppAgent(agent) {
    return normalizeAgent(agent) === 'codexapp';
  }

  function getDraftNoteKey(agent = currentAgent) {
    return `draft:${normalizeAgent(agent)}`;
  }

  function getCurrentNoteKey(agent = currentAgent) {
    return currentSessionId || getDraftNoteKey(agent);
  }

  function getSessionQueueKey(sessionId) {
    return sessionId ? `session:${sessionId}` : '';
  }

  function getDraftQueueKey(agent = currentAgent) {
    return `queue:${getDraftNoteKey(agent)}`;
  }

  function getCurrentQueueKey(agent = currentAgent) {
    return currentSessionId ? getSessionQueueKey(currentSessionId) : getDraftQueueKey(agent);
  }

  function supportsQueuedSend(agent = currentAgent) {
    return isCodexAppAgent(agent);
  }

  function getNotesForKey(key, create = true) {
    if (!key) return [];
    if (!pendingNotesByTarget.has(key)) {
      if (!create) return [];
      pendingNotesByTarget.set(key, []);
    }
    return pendingNotesByTarget.get(key);
  }

  function getCurrentNotes(create = true) {
    return getNotesForKey(getCurrentNoteKey(), create);
  }

  function cleanupNoteKey(key) {
    const notes = pendingNotesByTarget.get(key);
    if (!notes || notes.length === 0) pendingNotesByTarget.delete(key);
  }

  function getQueueForKey(key, create = true) {
    if (!key) return [];
    if (!queuedMessagesByTarget.has(key)) {
      if (!create) return [];
      queuedMessagesByTarget.set(key, []);
    }
    return queuedMessagesByTarget.get(key);
  }

  function getCurrentQueue(create = true) {
    return getQueueForKey(getCurrentQueueKey(), create);
  }

  function cleanupQueueKey(key) {
    const queue = queuedMessagesByTarget.get(key);
    if (!queue || queue.length === 0) queuedMessagesByTarget.delete(key);
  }

  function migratePendingNotesToSession(sessionId, agent = currentAgent) {
    if (!sessionId) return;
    const draftKey = getDraftNoteKey(agent);
    const draftNotes = pendingNotesByTarget.get(draftKey);
    if (!draftNotes || draftNotes.length === 0) return;
    const sessionNotes = getNotesForKey(sessionId, true);
    sessionNotes.push(...draftNotes);
    pendingNotesByTarget.delete(draftKey);
  }

  function migrateQueuedMessagesToSession(sessionId, agent = currentAgent) {
    if (!sessionId) return;
    const draftKey = getDraftQueueKey(agent);
    const draftQueue = queuedMessagesByTarget.get(draftKey);
    if (!draftQueue || draftQueue.length === 0) return;
    const sessionQueue = getQueueForKey(getSessionQueueKey(sessionId), true);
    sessionQueue.push(...draftQueue);
    queuedMessagesByTarget.delete(draftKey);
  }

  function updateGenerationControls() {
    const noteActive = !!noteMode;
    const allowRuntimeInsert = isGenerating && isCodexAppAgent(currentAgent) && !noteActive;
    const sendLabel = noteActive ? '记录笔记' : (allowRuntimeInsert ? '插入' : '发送');
    if (sendBtn) {
      sendBtn.classList.toggle('note-send', noteActive);
      sendBtn.title = sendLabel;
      sendBtn.setAttribute('aria-label', sendLabel);
      sendBtn.hidden = isGenerating ? !(noteActive || allowRuntimeInsert) : false;
    }
    if (queueSendBtn) {
      const queueAvailable = noteActive && supportsQueuedSend();
      const queueLabel = isGenerating || currentSessionRunning ? '排队发送' : '排队发送（空闲时将立即发送）';
      queueSendBtn.hidden = !queueAvailable;
      queueSendBtn.disabled = !queueAvailable;
      queueSendBtn.title = queueLabel;
      queueSendBtn.setAttribute('aria-label', queueLabel);
    }
    if (abortBtn) {
      abortBtn.hidden = !isGenerating;
    }
  }

  function updateNoteModeUI() {
    const active = !!noteMode;
    if (noteModeBtn) {
      noteModeBtn.classList.toggle('active', active);
      noteModeBtn.setAttribute('aria-pressed', active ? 'true' : 'false');
      noteModeBtn.title = active ? '关闭笔记模式' : '笔记模式';
      noteModeBtn.setAttribute('aria-label', active ? '关闭笔记模式' : '笔记模式');
    }
    if (inputWrapper) inputWrapper.classList.toggle('note-mode-active', active);
    if (msgInput) {
      msgInput.placeholder = active ? '记录笔记，稍后从气泡发送…' : defaultMsgInputPlaceholder;
    }
    if (active) hideCmdMenu();
    updateGenerationControls();
  }

  function createNoteActionButton(action, label, title = label) {
    const button = document.createElement('button');
    button.type = 'button';
    button.className = `note-action ${action}`;
    button.textContent = label;
    button.title = title;
    button.dataset.noteAction = action;
    return button;
  }

  function createPendingNoteElement(note) {
    const div = document.createElement('div');
    div.className = 'pending-note';
    div.dataset.noteId = note.id;

    const avatar = document.createElement('div');
    avatar.className = 'note-avatar';
    avatar.textContent = 'N';

    const bubble = document.createElement('div');
    bubble.className = 'note-bubble';

    const meta = document.createElement('div');
    meta.className = 'note-meta';
    meta.textContent = '笔记 · 待发送';

    const text = document.createElement('div');
    text.className = 'note-text';
    text.textContent = note.text;

    const actions = document.createElement('div');
    actions.className = 'note-actions';
    const editBtn = createNoteActionButton('edit', '修改');
    const deleteBtn = createNoteActionButton('delete', '删除');
    const sendNoteBtn = createNoteActionButton('send', '发送', '发送这条笔记');
    editBtn.addEventListener('click', () => beginEditPendingNote(note.id));
    deleteBtn.addEventListener('click', () => removePendingNote(note.id));
    sendNoteBtn.addEventListener('click', () => sendPendingNote(note.id));
    actions.append(editBtn, deleteBtn);
    if (supportsQueuedSend()) {
      const queueNoteBtn = createNoteActionButton('queue', '排队', '加入自动发送队列');
      queueNoteBtn.addEventListener('click', () => queuePendingNote(note.id));
      actions.appendChild(queueNoteBtn);
    }
    actions.appendChild(sendNoteBtn);

    bubble.append(meta, text, actions);
    div.append(avatar, bubble);
    return div;
  }

  function createQueuedMessageElement(message, index, total) {
    const div = document.createElement('div');
    div.className = 'pending-note queued-message';
    div.dataset.queueId = message.id;

    const avatar = document.createElement('div');
    avatar.className = 'note-avatar queue-avatar';
    avatar.textContent = 'Q';

    const bubble = document.createElement('div');
    bubble.className = 'note-bubble queue-bubble';

    const meta = document.createElement('div');
    meta.className = 'note-meta queue-meta';
    const waitLabel = isGenerating || currentSessionRunning ? '等待本轮结束' : '即将发送';
    meta.textContent = `队列 · 第 ${index + 1}/${total} 条 · ${waitLabel}`;

    const text = document.createElement('div');
    text.className = 'note-text';
    text.textContent = message.text;

    const actions = document.createElement('div');
    actions.className = 'note-actions';

    const upBtn = createNoteActionButton('move-up', '上移', '上移一位');
    upBtn.disabled = index <= 0;
    upBtn.addEventListener('click', () => moveQueuedMessage(message.id, -1));

    const downBtn = createNoteActionButton('move-down', '下移', '下移一位');
    downBtn.disabled = index >= total - 1;
    downBtn.addEventListener('click', () => moveQueuedMessage(message.id, 1));

    const editBtn = createNoteActionButton('edit', '修改');
    editBtn.addEventListener('click', () => beginEditQueuedMessage(message.id));

    const deleteBtn = createNoteActionButton('delete', '删除');
    deleteBtn.addEventListener('click', () => removeQueuedMessage(message.id));

    actions.append(upBtn, downBtn, editBtn, deleteBtn);
    bubble.append(meta, text, actions);
    div.append(avatar, bubble);
    return div;
  }

  function renderPendingNotes(options = {}) {
    if (!pendingNotesTray) return;
    pendingNotesTray.innerHTML = '';
    const notes = getCurrentNotes(false);
    const queuedMessages = getCurrentQueue(false);
    renderPendingCcwebPrompts({ scroll: false, updateScrollbar: false });
    if ((!notes || notes.length === 0) && (!queuedMessages || queuedMessages.length === 0)) {
      pendingNotesTray.hidden = true;
      if (options.updateScrollbar !== false) updateScrollbar();
      return;
    }

    const frag = document.createDocumentFragment();
    notes.forEach((note) => frag.appendChild(createPendingNoteElement(note)));
    queuedMessages.forEach((message, index) => {
      frag.appendChild(createQueuedMessageElement(message, index, queuedMessages.length));
    });
    pendingNotesTray.appendChild(frag);
    pendingNotesTray.hidden = false;
    if (options.scrollIntoView !== false && options.scroll !== false) {
      pendingNotesTray.scrollTop = pendingNotesTray.scrollHeight;
    }
    if (options.updateScrollbar !== false) updateScrollbar();
  }

  function collectCurrentPendingCcwebPrompts() {
    if (!currentSessionId) return [];
    const prompts = [];
    const seen = new Set();
    const entry = sessionCache.get(currentSessionId);
    const messages = Array.isArray(entry?.snapshot?.messages) ? entry.snapshot.messages : [];
    messages.forEach((message) => {
      const prompt = message?.ccwebPrompt;
      if (!prompt?.id || seen.has(prompt.id) || (prompt.status || 'pending') !== 'pending') return;
      seen.add(prompt.id);
      prompts.push(prompt);
    });
    messagesDiv?.querySelectorAll?.('.ccweb-prompt-card[data-status="pending"]').forEach((card) => {
      const promptId = card.dataset.promptId || '';
      if (!promptId || seen.has(promptId)) return;
      seen.add(promptId);
      prompts.push({
        id: promptId,
        title: card.querySelector('.ccweb-prompt-title')?.textContent || '需要用户确认',
        questions: Array.from(card.querySelectorAll('.ccweb-prompt-question')).map((questionEl) => ({
          id: questionEl.dataset.questionId || '',
        })),
      });
    });
    return prompts;
  }

  function scrollToCcwebPrompt(promptId) {
    if (!promptId || !messagesDiv) return false;
    const card = messagesDiv.querySelector(`.ccweb-prompt-card[data-prompt-id="${cssEscape(promptId)}"]`);
    if (!card) {
      showToast('未找到待提交表单', '可能在未加载的历史消息中');
      return false;
    }
    const target = card.closest('.msg') || card;
    const containerRect = messagesDiv.getBoundingClientRect();
    const targetRect = target.getBoundingClientRect();
    const targetTop = messagesDiv.scrollTop + targetRect.top - containerRect.top - 72;
    messagesDiv.scrollTo({ top: Math.max(0, targetTop), behavior: 'smooth' });
    card.classList.remove('ccweb-prompt-focus');
    requestAnimationFrame(() => {
      card.classList.add('ccweb-prompt-focus');
      window.setTimeout(() => card.classList.remove('ccweb-prompt-focus'), 1400);
    });
    updateScrollbar();
    return true;
  }

  function dismissCcwebPrompt(promptId) {
    if (!promptId || !currentSessionId) return;
    send({
      type: 'ccweb_prompt_user_dismiss',
      sessionId: currentSessionId,
      promptId,
    });
  }

  function createPendingCcwebPromptElement(prompt) {
    const item = document.createElement('div');
    item.className = 'pending-ccweb-prompt';
    item.dataset.promptId = prompt.id || '';

    const badge = document.createElement('span');
    badge.className = 'pending-ccweb-prompt-badge';
    badge.setAttribute('aria-label', '未提交');

    const title = document.createElement('div');
    title.className = 'pending-ccweb-prompt-title';
    const questionCount = Array.isArray(prompt.questions) ? prompt.questions.length : 0;
    title.textContent = `${prompt.title || '需要用户确认'} · ${questionCount || 1} 题`;

    const action = document.createElement('button');
    action.type = 'button';
    action.className = 'pending-ccweb-prompt-action';
    action.textContent = '定位';
    action.addEventListener('click', () => {
      closeCcwebPromptOutlinePanel();
      scrollToCcwebPrompt(prompt.id);
    });

    const dismiss = document.createElement('button');
    dismiss.type = 'button';
    dismiss.className = 'pending-ccweb-prompt-dismiss';
    dismiss.textContent = '忽略';
    dismiss.title = '忽略并删除这个未提交表单';
    dismiss.addEventListener('click', () => {
      dismiss.disabled = true;
      dismiss.textContent = '删除中';
      dismissCcwebPrompt(prompt.id);
    });

    item.append(badge, title, action, dismiss);
    return item;
  }

  function renderPendingCcwebPrompts(options = {}) {
    if (!ccwebPromptOutlineBtn || !ccwebPromptOutlinePanel) return;
    const prompts = collectCurrentPendingCcwebPrompts();
    const anchor = ccwebPromptOutlineBtn.closest('.ccweb-prompt-outline-anchor');
    if (prompts.length === 0) {
      if (anchor) anchor.hidden = true;
      closeCcwebPromptOutlinePanel();
      delete ccwebPromptOutlineBtn.dataset.count;
      ccwebPromptOutlinePanel.innerHTML = '';
      if (options.updateScrollbar !== false) updateScrollbar();
      return;
    }
    if (anchor) anchor.hidden = false;
    ccwebPromptOutlineBtn.disabled = false;
    ccwebPromptOutlineBtn.dataset.count = String(prompts.length);
    ccwebPromptOutlinePanel.replaceChildren(...prompts.map((prompt) => createPendingCcwebPromptElement(prompt)));
    if (options.updateScrollbar !== false) updateScrollbar();
  }

  function closeCcwebPromptOutlinePanel() {
    if (!ccwebPromptOutlinePanel || !ccwebPromptOutlineBtn) return;
    ccwebPromptOutlinePanel.hidden = true;
    ccwebPromptOutlineBtn.setAttribute('aria-expanded', 'false');
  }

  function toggleCcwebPromptOutlinePanel() {
    if (!ccwebPromptOutlinePanel || !ccwebPromptOutlineBtn) return;
    if (ccwebPromptOutlinePanel.hidden) {
      renderPendingCcwebPrompts({ scroll: false, updateScrollbar: false });
      const anchor = ccwebPromptOutlineBtn.closest('.ccweb-prompt-outline-anchor');
      if (anchor?.hidden || !ccwebPromptOutlinePanel.children.length) return;
      closeUserOutlinePanel();
      ccwebPromptOutlinePanel.hidden = false;
      ccwebPromptOutlineBtn.setAttribute('aria-expanded', 'true');
    } else {
      closeCcwebPromptOutlinePanel();
    }
  }

  function findPendingNote(noteId) {
    const key = getCurrentNoteKey();
    const notes = getNotesForKey(key, false);
    const index = notes.findIndex((note) => note.id === noteId);
    if (index === -1) return null;
    return { key, notes, index, note: notes[index] };
  }

  function dropPendingNote(noteId) {
    const found = findPendingNote(noteId);
    if (!found) return null;
    const [note] = found.notes.splice(found.index, 1);
    cleanupNoteKey(found.key);
    return note;
  }

  function addPendingNoteFromInput(text) {
    const content = String(text || '').trim();
    if (!content) return false;
    const note = {
      id: `note-${Date.now().toString(36)}-${++noteDraftSeq}`,
      text: content,
      createdAt: Date.now(),
    };
    getCurrentNotes(true).push(note);
    renderPendingNotes();
    return true;
  }

  function getQueuedMessageValidationError(content) {
    if (!supportsQueuedSend()) return '排队发送仅支持 Codex App。';
    if (!String(content || '').trim()) return '排队内容不能为空。';
    if (String(content || '').trim().startsWith('/')) return '排队发送暂不支持 slash 指令。';
    return '';
  }

  function addQueuedMessage(text, options = {}) {
    const content = String(text || '').trim();
    const validationError = getQueuedMessageValidationError(content);
    if (validationError) {
      appendError(validationError);
      return false;
    }
    const message = {
      id: `queued-${Date.now().toString(36)}-${++queuedMessageSeq}`,
      text: content,
      createdAt: Date.now(),
    };
    getCurrentQueue(true).push(message);
    if (options.render !== false) renderPendingNotes();
    scheduleQueuedMessageDrain();
    return true;
  }

  function queueMessageFromInput() {
    const text = msgInput.value.trim();
    if (!text || isBlockingSessionLoad()) return;
    hideCmdMenu();
    hideOptionPicker();
    if (pendingAttachments.length > 0) {
      appendError('排队发送暂不支持图片附件，请先移除图片。');
      return;
    }
    if (addQueuedMessage(text)) {
      msgInput.value = '';
      autoResize();
    }
  }

  function queuePendingNote(noteId) {
    const found = findPendingNote(noteId);
    if (!found) return;
    const text = String(found.note.text || '').trim();
    const validationError = getQueuedMessageValidationError(text);
    if (validationError) {
      appendError(validationError);
      return;
    }
    dropPendingNote(noteId);
    addQueuedMessage(text, { render: false });
    renderPendingNotes();
  }

  function findQueuedMessage(queueId) {
    const key = getCurrentQueueKey();
    const queue = getQueueForKey(key, false);
    const index = queue.findIndex((message) => message.id === queueId);
    if (index === -1) return null;
    return { key, queue, index, message: queue[index] };
  }

  function dropQueuedMessage(queueId) {
    const found = findQueuedMessage(queueId);
    if (!found) return null;
    const [message] = found.queue.splice(found.index, 1);
    cleanupQueueKey(found.key);
    return message;
  }

  function removeQueuedMessage(queueId) {
    if (!dropQueuedMessage(queueId)) return;
    renderPendingNotes({ scroll: false });
  }

  function moveQueuedMessage(queueId, delta) {
    const found = findQueuedMessage(queueId);
    if (!found) return;
    const nextIndex = found.index + delta;
    if (nextIndex < 0 || nextIndex >= found.queue.length) return;
    const [message] = found.queue.splice(found.index, 1);
    found.queue.splice(nextIndex, 0, message);
    renderPendingNotes({ scroll: false });
  }

  function removePendingNote(noteId) {
    if (!dropPendingNote(noteId)) return;
    renderPendingNotes({ scroll: false });
  }

  function resizeNoteEditor(editor) {
    editor.style.height = 'auto';
    editor.style.height = Math.min(editor.scrollHeight, 180) + 'px';
  }

  function beginEditPendingNote(noteId) {
    const found = findPendingNote(noteId);
    if (!found) return;
    const noteEl = pendingNotesTray?.querySelector(`.pending-note[data-note-id="${noteId}"]`);
    const bubble = noteEl?.querySelector('.note-bubble');
    if (!bubble) return;

    bubble.classList.add('editing');
    bubble.innerHTML = '';

    const meta = document.createElement('div');
    meta.className = 'note-meta';
    meta.textContent = '修改笔记';

    const editor = document.createElement('textarea');
    editor.className = 'note-edit-input';
    editor.value = found.note.text;
    editor.rows = 3;

    const actions = document.createElement('div');
    actions.className = 'note-actions';
    const saveBtn = createNoteActionButton('save', '保存');
    const cancelBtn = createNoteActionButton('cancel', '取消');

    const save = () => {
      const next = editor.value.trim();
      if (!next) {
        appendError('笔记内容不能为空。');
        editor.focus();
        return;
      }
      found.note.text = next;
      renderPendingNotes();
    };

    saveBtn.addEventListener('click', save);
    cancelBtn.addEventListener('click', () => renderPendingNotes({ scroll: false }));
    editor.addEventListener('input', () => resizeNoteEditor(editor));
    editor.addEventListener('keydown', (e) => {
      if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
        e.preventDefault();
        save();
      }
      if (e.key === 'Escape') {
        e.preventDefault();
        renderPendingNotes({ scroll: false });
      }
    });

    actions.append(saveBtn, cancelBtn);
    bubble.append(meta, editor, actions);
    requestAnimationFrame(() => {
      resizeNoteEditor(editor);
      editor.focus();
      editor.select();
    });
  }

  function beginEditQueuedMessage(queueId) {
    const found = findQueuedMessage(queueId);
    if (!found) return;
    const queueEl = pendingNotesTray?.querySelector(`.queued-message[data-queue-id="${queueId}"]`);
    const bubble = queueEl?.querySelector('.note-bubble');
    if (!bubble) return;

    bubble.classList.add('editing');
    bubble.innerHTML = '';

    const meta = document.createElement('div');
    meta.className = 'note-meta queue-meta';
    meta.textContent = '修改排队消息';

    const editor = document.createElement('textarea');
    editor.className = 'note-edit-input';
    editor.value = found.message.text;
    editor.rows = 3;

    const actions = document.createElement('div');
    actions.className = 'note-actions';
    const saveBtn = createNoteActionButton('save', '保存');
    const cancelBtn = createNoteActionButton('cancel', '取消');

    const save = () => {
      const next = editor.value.trim();
      const validationError = getQueuedMessageValidationError(next);
      if (validationError) {
        appendError(validationError);
        editor.focus();
        return;
      }
      found.message.text = next;
      renderPendingNotes({ scroll: false });
    };

    saveBtn.addEventListener('click', save);
    cancelBtn.addEventListener('click', () => renderPendingNotes({ scroll: false }));
    editor.addEventListener('input', () => resizeNoteEditor(editor));
    editor.addEventListener('keydown', (e) => {
      if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
        e.preventDefault();
        save();
      }
      if (e.key === 'Escape') {
        e.preventDefault();
        renderPendingNotes({ scroll: false });
      }
    });

    actions.append(saveBtn, cancelBtn);
    bubble.append(meta, editor, actions);
    requestAnimationFrame(() => {
      resizeNoteEditor(editor);
      editor.focus();
      editor.select();
    });
  }

  function sendPendingNote(noteId) {
    if (isGenerating || isBlockingSessionLoad()) {
      appendError('当前回复还在生成，稍后再发送笔记。');
      return;
    }
    const note = dropPendingNote(noteId);
    if (!note) return;
    const text = String(note.text || '').trim();
    renderPendingNotes({ scroll: false });
    if (!text) return;
    submitUserMessage(text);
  }

  function scheduleQueuedMessageDrain() {
    if (queuedMessageDrainTimer) return;
    queuedMessageDrainTimer = setTimeout(() => {
      queuedMessageDrainTimer = null;
      drainQueuedMessages();
    }, 0);
  }

  function drainQueuedMessages() {
    if (!supportsQueuedSend() || isGenerating || currentSessionRunning || isBlockingSessionLoad()) return;
    const queue = getCurrentQueue(false);
    if (!queue || queue.length === 0) return;
    const [message] = queue.splice(0, 1);
    cleanupQueueKey(getCurrentQueueKey());
    renderPendingNotes({ scroll: false });

    const text = String(message?.text || '').trim();
    if (!text) {
      scheduleQueuedMessageDrain();
      return;
    }
    submitUserMessage(text);
  }

  function normalizeTheme(theme) {
    return THEME_OPTIONS.some((item) => item.value === theme) ? theme : 'washi';
  }

  function getThemeOption(theme) {
    return THEME_OPTIONS.find((item) => item.value === normalizeTheme(theme)) || THEME_OPTIONS[0];
  }

  function refreshThemeSummaries() {
    const label = getThemeOption(currentTheme).label;
    document.querySelectorAll('[data-theme-summary]').forEach((node) => {
      node.textContent = label;
    });
  }

  function applyTheme(theme) {
    currentTheme = normalizeTheme(theme);
    document.documentElement.dataset.theme = currentTheme;
    localStorage.setItem('cc-web-theme', currentTheme);
    refreshThemeSummaries();
  }

  function getDividerTimeSummary() {
    return showAgentDividerTime ? '显示时间' : '不显示时间';
  }

  function refreshDividerTimeControls(root = document) {
    root.querySelectorAll('[data-divider-time-summary]').forEach((node) => {
      node.textContent = getDividerTimeSummary();
    });
    root.querySelectorAll('[data-divider-time-toggle]').forEach((node) => {
      node.checked = showAgentDividerTime;
    });
  }

  function applyDividerTimePreference(visible) {
    showAgentDividerTime = !!visible;
    document.documentElement.dataset.dividerTime = showAgentDividerTime ? 'show' : 'hide';
    localStorage.setItem(DIVIDER_TIME_STORAGE_KEY, showAgentDividerTime ? '1' : '0');
    refreshDividerTimeControls();
  }

  function buildThemePickerHtml(options = {}) {
    const { showSectionTitle = true } = options;
    return `
      ${showSectionTitle ? '<div class="settings-section-title">界面主题</div>' : ''}
      <div class="theme-grid">
        ${THEME_OPTIONS.map((theme) => `
          <button class="theme-card${theme.value === currentTheme ? ' active' : ''}" type="button" data-theme-value="${theme.value}">
            <div class="theme-card-preview">
              ${theme.swatches.map((color) => `<span class="theme-card-swatch" style="background:${color}"></span>`).join('')}
            </div>
            <div class="theme-card-title">${escapeHtml(theme.label)}</div>
            <div class="theme-card-desc">${escapeHtml(theme.desc)}</div>
          </button>
        `).join('')}
      </div>
    `;
  }

  function mountThemePicker(panel) {
    panel.querySelectorAll('[data-theme-value]').forEach((button) => {
      button.addEventListener('click', () => {
        applyTheme(button.dataset.themeValue);
        panel.querySelectorAll('[data-theme-value]').forEach((item) => {
          item.classList.toggle('active', item.dataset.themeValue === currentTheme);
        });
      });
    });
  }

  function buildAppearanceSettingsHtml() {
    return `
      <div class="settings-section-title">外观</div>
      <button class="settings-nav-card" type="button" data-open-theme-page>
        <span class="settings-nav-card-main">
          <span class="settings-nav-card-title">界面主题</span>
          <span class="settings-nav-card-meta">当前：<span data-theme-summary>${escapeHtml(getThemeOption(currentTheme).label)}</span></span>
        </span>
        <span class="settings-nav-card-arrow" aria-hidden="true">›</span>
      </button>
      <label class="settings-toggle-row">
        <span class="settings-toggle-copy">
          <span class="settings-toggle-title">分隔线时间</span>
          <span class="settings-toggle-meta">当前：<span data-divider-time-summary>${escapeHtml(getDividerTimeSummary())}</span></span>
        </span>
        <span class="settings-switch">
          <input type="checkbox" data-divider-time-toggle ${showAgentDividerTime ? 'checked' : ''}>
          <span class="settings-switch-track" aria-hidden="true">
            <span class="settings-switch-thumb"></span>
          </span>
        </span>
      </label>
    `;
  }

  function mountAppearanceSettings(panel) {
    const themePageBtn = panel.querySelector('[data-open-theme-page]');
    if (themePageBtn) themePageBtn.addEventListener('click', openThemeSubpage);
    const dividerTimeToggle = panel.querySelector('[data-divider-time-toggle]');
    if (dividerTimeToggle) {
      dividerTimeToggle.checked = showAgentDividerTime;
      dividerTimeToggle.addEventListener('change', () => {
        applyDividerTimePreference(dividerTimeToggle.checked);
      });
    }
    refreshDividerTimeControls(panel);
  }

  function buildNotifyEntryHtml(config) {
    const provider = config?.provider || 'off';
    const providerLabel = PROVIDER_OPTIONS.find(o => o.value === provider)?.label || '关闭';
    const summaryOn = config?.summary?.enabled ? '摘要已启用' : '摘要关闭';
    const meta = provider === 'off' ? '未启用' : `${providerLabel} · ${summaryOn}`;
    return `
      <div class="settings-section-title">通知</div>
      <button class="settings-nav-card" type="button" data-open-notify-page>
        <span class="settings-nav-card-main">
          <span class="settings-nav-card-title">通知设置</span>
          <span class="settings-nav-card-meta" data-notify-summary>${escapeHtml(meta)}</span>
        </span>
        <span class="settings-nav-card-arrow" aria-hidden="true">›</span>
      </button>
    `;
  }

  function openNotifySubpage() {
    send({ type: 'get_notify_config' });

    const overlay = document.createElement('div');
    overlay.className = 'settings-overlay settings-subpage-overlay';
    overlay.style.zIndex = '10001';

    const panel = document.createElement('div');
    panel.className = 'settings-panel settings-subpage-panel';
    panel.innerHTML = `
      <div class="settings-header settings-subpage-header">
        <button class="settings-back" type="button" aria-label="返回">‹</button>
        <div class="settings-subpage-copy">
          <div class="settings-subpage-kicker">Notification</div>
          <h3>通知设置</h3>
        </div>
      </div>
      <div class="settings-field">
        <label>通知方式</label>
        <select class="settings-select" id="notify-provider">
          ${PROVIDER_OPTIONS.map(o => `<option value="${o.value}">${escapeHtml(o.label)}</option>`).join('')}
        </select>
      </div>
      <div id="notify-fields"></div>
      <div id="notify-summary-area"></div>
      <div class="settings-actions">
        <button class="btn-test" id="notify-test-btn">测试</button>
        <button class="btn-save" id="notify-save-btn">保存</button>
      </div>
      <div class="settings-status" id="notify-status"></div>
    `;

    overlay.appendChild(panel);
    document.body.appendChild(overlay);

    const providerSelect = panel.querySelector('#notify-provider');
    const fieldsDiv = panel.querySelector('#notify-fields');
    const summaryArea = panel.querySelector('#notify-summary-area');
    const statusDiv = panel.querySelector('#notify-status');
    const testBtn = panel.querySelector('#notify-test-btn');
    const saveBtn = panel.querySelector('#notify-save-btn');

    let currentNotifyConfig = null;

    function renderFields(provider) {
      renderNotifyFields(fieldsDiv, currentNotifyConfig, provider);
      if (summaryArea) {
        summaryArea.innerHTML = buildSummarySettingsHtml(currentNotifyConfig);
        bindSummarySettingsEvents(panel);
      }
    }

    function collectConfig() {
      return collectNotifyConfigFromPanel(panel, currentNotifyConfig, providerSelect.value);
    }

    function showStatus(msg, type) {
      statusDiv.textContent = msg;
      statusDiv.className = 'settings-status ' + (type || '');
    }

    function refreshParentSummary(config) {
      const provider = config?.provider || 'off';
      const providerLabel = PROVIDER_OPTIONS.find(o => o.value === provider)?.label || '关闭';
      const summaryOn = config?.summary?.enabled ? '摘要已启用' : '摘要关闭';
      const meta = provider === 'off' ? '未启用' : `${providerLabel} · ${summaryOn}`;
      document.querySelectorAll('[data-notify-summary]').forEach(el => { el.textContent = meta; });
    }

    const savedOnNotifyConfig = _onNotifyConfig;
    _onNotifyConfig = (config) => {
      currentNotifyConfig = config;
      providerSelect.value = config.provider || 'off';
      renderFields(config.provider || 'off');
      if (savedOnNotifyConfig) savedOnNotifyConfig(config);
    };

    const savedOnNotifyTestResult = _onNotifyTestResult;
    _onNotifyTestResult = (msg) => {
      showStatus(msg.message, msg.success ? 'success' : 'error');
      if (savedOnNotifyTestResult) savedOnNotifyTestResult(msg);
    };

    providerSelect.addEventListener('change', () => renderFields(providerSelect.value));

    testBtn.addEventListener('click', () => {
      const config = collectConfig();
      send({ type: 'save_notify_config', config });
      showStatus('正在发送测试消息...', '');
      send({ type: 'test_notify' });
    });

    saveBtn.addEventListener('click', () => {
      const config = collectConfig();
      send({ type: 'save_notify_config', config });
      refreshParentSummary(config);
      showStatus('已保存', 'success');
    });

    const closeSubpage = () => {
      _onNotifyConfig = savedOnNotifyConfig;
      _onNotifyTestResult = savedOnNotifyTestResult;
      if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
    };

    panel.querySelector('.settings-back').addEventListener('click', closeSubpage);
    overlay.addEventListener('click', (e) => { if (e.target === overlay) closeSubpage(); });
  }

  function openThemeSubpage() {
    const overlay = document.createElement('div');
    overlay.className = 'settings-overlay settings-subpage-overlay';
    overlay.style.zIndex = '10001';

    const panel = document.createElement('div');
    panel.className = 'settings-panel settings-subpage-panel';
    panel.innerHTML = `
      <div class="settings-header settings-subpage-header">
        <button class="settings-back" type="button" aria-label="返回">‹</button>
        <div class="settings-subpage-copy">
          <div class="settings-subpage-kicker">Appearance</div>
          <h3>界面主题</h3>
        </div>
        <button class="settings-close" type="button" title="关闭">&times;</button>
      </div>
      ${buildThemePickerHtml({ showSectionTitle: false })}
    `;

    overlay.appendChild(panel);
    document.body.appendChild(overlay);
    mountThemePicker(panel);
    refreshThemeSummaries();

    const closeSubpage = () => {
      if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
    };

    panel.querySelector('.settings-back').addEventListener('click', closeSubpage);
    panel.querySelector('.settings-close').addEventListener('click', closeSubpage);
    overlay.addEventListener('click', (e) => {
      if (e.target === overlay) closeSubpage();
    });
  }

  function getAgentSessionStorageKey(agent) {
    return `cc-web-session-${normalizeAgent(agent)}`;
  }

  function getAgentModeStorageKey(agent) {
    return `cc-web-mode-${normalizeAgent(agent)}`;
  }

  function getLastSessionForAgent(agent) {
    return localStorage.getItem(getAgentSessionStorageKey(agent));
  }

  function setLastSessionForAgent(agent, sessionId) {
    localStorage.setItem(getAgentSessionStorageKey(agent), sessionId);
    localStorage.setItem('cc-web-session', sessionId);
  }

  function getSessionMeta(sessionId) {
    return sessions.find((s) => s.id === sessionId) || null;
  }

  function compareSessionUpdatedDesc(a, b) {
    return new Date(b?.updated || 0) - new Date(a?.updated || 0);
  }

  function compareSessionPinnedDesc(a, b) {
    const pinnedDiff = new Date(b?.pinnedAt || 0) - new Date(a?.pinnedAt || 0);
    return pinnedDiff || compareSessionUpdatedDesc(a, b);
  }

  function shortSessionId(sessionId) {
    const value = String(sessionId || '');
    return value ? value.slice(0, 8) : '';
  }

  function shortChildAgentId(threadId) {
    const value = String(threadId || '');
    if (!value) return '';
    if (value.length <= 13) return value;
    return `${value.slice(0, 8)}…${value.slice(-4)}`;
  }

  function shortMessagePreview(text, maxLength = 60) {
    const value = String(text || '').replace(/\s+/g, ' ').trim();
    if (!value) return '空消息';
    return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value;
  }

  function getCrossConversationReplyCollapseKey(meta = {}) {
    const source = meta?.crossConversation || {};
    const messageId = source.replyToRequestId
      || source.messageId
      || meta.messageId
      || meta.id
      || [source.sourceSessionId, meta.timestamp || source.processedAt || source.sentAt].filter(Boolean).join(':');
    if (!messageId) return '';
    return `${currentSessionId || 'unknown'}:${messageId}`;
  }

  function persistCrossConversationReplyCollapseState() {
    try {
      const keys = Array.from(collapsedCrossConversationReplyKeys).slice(-CROSS_CONVERSATION_REPLY_COLLAPSE_LIMIT);
      localStorage.setItem(CROSS_CONVERSATION_REPLY_COLLAPSE_STORAGE_KEY, JSON.stringify(keys));
    } catch {}
  }

  function setCrossConversationReplyCollapsed(key, collapsed) {
    if (!key) return;
    if (collapsed) {
      collapsedCrossConversationReplyKeys.delete(key);
      collapsedCrossConversationReplyKeys.add(key);
    } else {
      collapsedCrossConversationReplyKeys.delete(key);
    }
    persistCrossConversationReplyCollapseState();
  }

  function isCrossConversationReplyCollapsed(key) {
    return !!key && collapsedCrossConversationReplyKeys.has(key);
  }

  function formatCrossConversationReplyTime(value) {
    if (!value) return '';
    const date = new Date(value);
    if (Number.isNaN(date.getTime())) return '';
    return date.toLocaleString('zh-CN', {
      month: '2-digit',
      day: '2-digit',
      hour: '2-digit',
      minute: '2-digit',
    });
  }

  function createLocalId(prefix = 'local') {
    if (window.crypto?.randomUUID) return `${prefix}-${window.crypto.randomUUID()}`;
    return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
  }

  function rememberPendingSlashDraft(requestId, text, sessionId, agent) {
    const id = String(requestId || '').trim();
    if (!id) return;
    while (pendingSlashDraftsByRequestId.size >= PENDING_SLASH_DRAFT_LIMIT) {
      const oldest = pendingSlashDraftsByRequestId.keys().next().value;
      if (!oldest) break;
      pendingSlashDraftsByRequestId.delete(oldest);
    }
    pendingSlashDraftsByRequestId.set(id, {
      text,
      sessionId: sessionId || null,
      agent: normalizeAgent(agent),
      createdAt: Date.now(),
    });
  }

  function applyPendingSlashDraftResponse(msg) {
    const requestId = String(msg?.requestId || '').trim();
    if (!requestId) return false;
    const draft = pendingSlashDraftsByRequestId.get(requestId);
    if (!draft) return false;
    pendingSlashDraftsByRequestId.delete(requestId);
    if (!msg.preserveComposerDraft) return false;
    if (draft.sessionId && currentSessionId && draft.sessionId !== currentSessionId) return false;
    if (normalizeAgent(draft.agent) !== normalizeAgent(currentAgent)) return false;
    if (msgInput.value !== '' || pendingAttachments.length > 0) return false;
    msgInput.value = draft.text;
    autoResize();
    return true;
  }

  function clearUserMessageIndex() {
    userMessageIndex.clear();
  }

  function registerUserMessage(messageId, element, content) {
    if (!messageId || !element) return;
    userMessageIndex.set(messageId, {
      id: messageId,
      element,
      content: String(content || ''),
    });
  }

  function buildUserOutlineItems() {
    const seen = new Set();
    return Array.from(messagesDiv.querySelectorAll('.msg.user[data-message-id]')).map((element) => {
      const id = String(element.dataset.messageId || '').trim();
      if (!id || seen.has(id)) return null;
      seen.add(id);
      const indexed = userMessageIndex.get(id);
      const content = indexed?.content || element.querySelector('.msg-text')?.textContent || '';
      return {
        id,
        targetMessageId: element.id || '',
        label: shortMessagePreview(content, 64),
      };
    }).filter((entry) => entry && entry.targetMessageId);
  }

  function updateUserOutlinePanel() {
    if (!userOutlinePanel || !userOutlineBtn) return;
    const items = buildUserOutlineItems();
    if (items.length === 0) {
      userOutlinePanel.innerHTML = '<div class="user-outline-empty">暂无用户消息</div>';
      userOutlineBtn.disabled = true;
    } else {
      userOutlinePanel.innerHTML = items.map((item, index) => `
        <button type="button" class="user-outline-item" data-target="${escapeHtml(item.targetMessageId)}">
          <span class="user-outline-index">${index + 1}</span>
          <span class="user-outline-text">${escapeHtml(item.label)}</span>
        </button>
      `).join('');
      userOutlineBtn.disabled = false;
    }
  }

  function closeUserOutlinePanel() {
    if (!userOutlinePanel || !userOutlineBtn) return;
    userOutlinePanel.hidden = true;
    userOutlineBtn.setAttribute('aria-expanded', 'false');
  }

  function toggleUserOutlinePanel() {
    if (!userOutlinePanel || !userOutlineBtn) return;
    if (userOutlinePanel.hidden) {
      updateUserOutlinePanel();
      closeCcwebPromptOutlinePanel();
      userOutlinePanel.hidden = false;
      userOutlineBtn.setAttribute('aria-expanded', 'true');
    } else {
      closeUserOutlinePanel();
    }
  }

  function scrollToMessage(anchorId) {
    if (!anchorId) return;
    const target = document.getElementById(anchorId);
    if (!target || !messagesDiv.contains(target)) return;
    const containerRect = messagesDiv.getBoundingClientRect();
    const targetRect = target.getBoundingClientRect();
    const targetTop = messagesDiv.scrollTop + targetRect.top - containerRect.top - 12;
    messagesDiv.scrollTo({ top: Math.max(0, targetTop), behavior: 'smooth' });
  }

  function isAssistantLastSectionTextNode(node, root) {
    if (!node || node.nodeType !== Node.TEXT_NODE || !node.nodeValue?.trim()) return false;
    const parent = node.parentElement;
    if (!parent || !root.contains(parent)) return false;
    if (parent.closest(ASSISTANT_LAST_SECTION_SKIP_SELECTOR)) return false;
    const tag = parent.tagName?.toLowerCase();
    return !['button', 'script', 'style', 'textarea', 'input', 'select', 'option'].includes(tag);
  }

  function collectAssistantTextNodes(root) {
    const nodes = [];
    if (!root) return nodes;
    const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
      acceptNode(node) {
        return isAssistantLastSectionTextNode(node, root)
          ? NodeFilter.FILTER_ACCEPT
          : NodeFilter.FILTER_REJECT;
      },
    });
    let node = walker.nextNode();
    while (node) {
      nodes.push(node);
      node = walker.nextNode();
    }
    return nodes;
  }

  function findLastAssistantTextScope(bubble) {
    const nodes = collectAssistantTextNodes(bubble);
    const lastNode = nodes[nodes.length - 1];
    if (!lastNode) return null;
    return lastNode.parentElement?.closest(ASSISTANT_LAST_SECTION_SCOPE_SELECTOR) || bubble;
  }

  function collectAssistantTextScopes(root) {
    if (!root) return [];
    const seen = new Set();
    return Array.from(root.querySelectorAll(ASSISTANT_LAST_SECTION_SCOPE_SELECTOR)).filter((scope) => {
      if (!scope || seen.has(scope)) return false;
      seen.add(scope);
      if (scope.closest(ASSISTANT_LAST_SECTION_SKIP_SELECTOR)) return false;
      return collectAssistantTextNodes(scope).length > 0;
    });
  }

  function findFirstAssistantTextScopeAfterDivider(bubble) {
    const dividers = Array.from(bubble?.querySelectorAll?.('.agent-message-divider') || []);
    const lastDivider = dividers[dividers.length - 1];
    if (!lastDivider) return null;
    return collectAssistantTextScopes(bubble).find((scope) => (
      lastDivider.compareDocumentPosition(scope) & Node.DOCUMENT_POSITION_FOLLOWING
    )) || null;
  }

  function findFirstNonWhitespaceIndex(text, start = 0) {
    for (let i = Math.max(0, start); i < text.length; i += 1) {
      if (!/\s/.test(text[i])) return i;
    }
    return -1;
  }

  function mapTextIndexToNode(entries, index) {
    for (const entry of entries) {
      if (index >= entry.start && index < entry.end) {
        return { node: entry.node, offset: index - entry.start };
      }
    }
    const last = entries[entries.length - 1];
    return last ? { node: last.node, offset: last.node.nodeValue.length } : null;
  }

  function getAssistantTextScopeStartTarget(scope) {
    if (!scope) return null;
    const nodes = collectAssistantTextNodes(scope);
    if (nodes.length === 0) return null;
    const entries = [];
    let text = '';
    nodes.forEach((node) => {
      const value = node.nodeValue || '';
      const start = text.length;
      text += value;
      entries.push({ node, start, end: text.length });
    });
    const startIndex = findFirstNonWhitespaceIndex(text, 0);
    if (startIndex < 0) return null;
    const mapped = mapTextIndexToNode(entries, startIndex);
    return mapped ? { ...mapped, scope } : null;
  }

  function getAssistantLastSectionTarget(bubble) {
    const scope = findFirstAssistantTextScopeAfterDivider(bubble) || findLastAssistantTextScope(bubble);
    return getAssistantTextScopeStartTarget(scope);
  }

  function getRangeRectFromTextPosition(node, offset) {
    if (!node) return null;
    const range = document.createRange();
    const safeOffset = Math.min(Math.max(0, offset), node.nodeValue.length);
    range.setStart(node, safeOffset);
    range.setEnd(node, Math.min(node.nodeValue.length, safeOffset + 1));
    const rect = Array.from(range.getClientRects()).find(item => item.width || item.height) || null;
    range.detach?.();
    return rect;
  }

  function scrollAssistantBubbleToLastSection(bubble) {
    const target = getAssistantLastSectionTarget(bubble);
    if (!target) return false;
    const rect = getRangeRectFromTextPosition(target.node, target.offset) || target.scope.getBoundingClientRect();
    if (!rect) return false;
    const containerRect = messagesDiv.getBoundingClientRect();
    const targetTop = messagesDiv.scrollTop + rect.top - containerRect.top - ASSISTANT_LAST_SECTION_SCROLL_OFFSET;
    messagesDiv.scrollTo({ top: Math.max(0, targetTop), behavior: 'smooth' });
    target.scope.classList.remove(ASSISTANT_LAST_SECTION_FOCUS_CLASS);
    requestAnimationFrame(() => {
      target.scope.classList.add(ASSISTANT_LAST_SECTION_FOCUS_CLASS);
      window.setTimeout(() => target.scope.classList.remove(ASSISTANT_LAST_SECTION_FOCUS_CLASS), 1100);
    });
    updateScrollbar();
    return true;
  }

  function createAssistantLastSectionButton() {
    const button = document.createElement('button');
    button.type = 'button';
    button.className = ASSISTANT_LAST_SECTION_BUTTON_CLASS;
    button.title = '定位到本条回复最后一段';
    button.setAttribute('aria-label', '定位到本条回复最后一段');
    button.innerHTML = `
      <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
        <circle cx="12" cy="12" r="7"></circle>
        <circle cx="12" cy="12" r="2"></circle>
        <path d="M12 2v3"></path>
        <path d="M12 19v3"></path>
        <path d="M2 12h3"></path>
        <path d="M19 12h3"></path>
      </svg>
    `;
    button.addEventListener('click', (event) => {
      event.preventDefault();
      event.stopPropagation();
      const bubble = button.closest('.msg-bubble');
      scrollAssistantBubbleToLastSection(bubble);
    });
    return button;
  }

  function getMessageActionRow(bubble) {
    if (!bubble) return null;
    let row = bubble.querySelector(':scope > .msg-action-row');
    if (!row) {
      row = document.createElement('div');
      row.className = 'msg-action-row';
      bubble.appendChild(row);
    }
    return row;
  }

  function syncAssistantLastSectionButton(messageEl) {
    if (!messageEl?.classList?.contains('assistant')) return;
    const bubble = messageEl.querySelector(':scope > .msg-bubble');
    if (!bubble) return;
    let button = bubble.querySelector(`:scope .${ASSISTANT_LAST_SECTION_BUTTON_CLASS}`);
    const hasTarget = !!getAssistantLastSectionTarget(bubble);
    if (!button && !hasTarget) return;
    if (!button) button = createAssistantLastSectionButton();
    button.hidden = !hasTarget;
    button.disabled = !hasTarget;
    getMessageActionRow(bubble)?.appendChild(button);
  }

  function createAssistantBranchButton(messageIndex) {
    const button = document.createElement('button');
    button.type = 'button';
    button.className = ASSISTANT_BRANCH_BUTTON_CLASS;
    button.title = '从这里分支新会话';
    button.setAttribute('aria-label', '从这里分支新会话');
    button.dataset.messageIndex = String(messageIndex);
    button.innerHTML = `
      <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
        <path d="M6 3v12"></path>
        <circle cx="6" cy="18" r="3"></circle>
        <circle cx="18" cy="6" r="3"></circle>
        <path d="M6 9h6a6 6 0 0 0 6-6"></path>
        <path d="M15 18h6"></path>
        <path d="M18 15v6"></path>
      </svg>
    `;
    button.addEventListener('click', (event) => {
      event.preventDefault();
      event.stopPropagation();
      const index = Number.parseInt(button.dataset.messageIndex || '', 10);
      branchFromAssistantMessage(index);
    });
    return button;
  }

  function syncAssistantBranchButton(messageEl, messageIndex) {
    if (!messageEl?.classList?.contains('assistant')) return;
    if (!currentSessionId || !Number.isFinite(messageIndex) || messageIndex < 0) return;
    const bubble = messageEl.querySelector(':scope > .msg-bubble');
    if (!bubble) return;
    let button = bubble.querySelector(`:scope .${ASSISTANT_BRANCH_BUTTON_CLASS}`);
    if (!button) button = createAssistantBranchButton(messageIndex);
    button.dataset.messageIndex = String(messageIndex);
    getMessageActionRow(bubble)?.appendChild(button);
  }

  function markSessionMessageElement(messageEl, messageIndex) {
    if (!messageEl || !Number.isFinite(messageIndex) || messageIndex < 0) return;
    messageEl.dataset.sessionMessage = 'true';
    messageEl.dataset.messageIndex = String(messageIndex);
    if (messageEl.classList.contains('assistant')) {
      syncAssistantBranchButton(messageEl, messageIndex);
    }
  }

  function updateSessionIdBadge() {
    if (!chatSessionIdBtn) return;
    if (!currentSessionId) {
      chatSessionIdBtn.hidden = true;
      chatSessionIdBtn.textContent = 'ID';
      chatSessionIdBtn.title = '复制当前会话 ID';
      return;
    }
    chatSessionIdBtn.hidden = false;
    chatSessionIdBtn.textContent = `ID ${shortSessionId(currentSessionId)}`;
    chatSessionIdBtn.title = `复制当前会话 ID\n${currentSessionId}`;
    chatSessionIdBtn.setAttribute('aria-label', `复制当前会话 ID ${currentSessionId}`);
  }

  function shouldPreferTextareaCopy() {
    const ua = navigator.userAgent || '';
    return /iPad|iPhone|iPod/.test(ua)
      || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
  }

  function copyTextWithTextarea(value) {
    const textarea = document.createElement('textarea');
    textarea.value = value;
    textarea.setAttribute('readonly', '');
    textarea.setAttribute('aria-hidden', 'true');
    textarea.style.position = 'fixed';
    textarea.style.top = '0';
    textarea.style.left = '0';
    textarea.style.width = '1px';
    textarea.style.height = '1px';
    textarea.style.padding = '0';
    textarea.style.border = '0';
    textarea.style.opacity = '0';
    textarea.style.pointerEvents = 'none';
    textarea.style.fontSize = '16px';
    document.body.appendChild(textarea);

    const activeElement = document.activeElement;
    try {
      try {
        textarea.focus({ preventScroll: true });
      } catch {
        textarea.focus();
      }
      textarea.select();
      try {
        textarea.setSelectionRange(0, value.length);
      } catch {}
      if (!document.execCommand('copy')) throw new Error('copy_failed');
    } finally {
      textarea.remove();
      if (activeElement && typeof activeElement.focus === 'function') {
        try {
          activeElement.focus({ preventScroll: true });
        } catch {}
      }
    }
  }

  async function copyTextToClipboard(text, successText = '已复制') {
    const value = String(text || '');
    if (!value) return false;
    try {
      let copied = false;
      const preferTextarea = shouldPreferTextareaCopy();
      if (preferTextarea) {
        try {
          copyTextWithTextarea(value);
          copied = true;
        } catch {}
      }
      if (!copied && navigator.clipboard?.writeText && window.isSecureContext) {
        try {
          await navigator.clipboard.writeText(value);
          copied = true;
        } catch {}
      }
      if (!copied) copyTextWithTextarea(value);
      showToast(successText);
      return true;
    } catch {
      showToast('复制失败');
      return false;
    }
  }

  function deepClone(value) {
    if (value === null || value === undefined) return value;
    return JSON.parse(JSON.stringify(value));
  }

  function cloneMessages(messages) {
    return Array.isArray(messages) ? deepClone(messages) : [];
  }

  function estimateSessionMessageWeight(message) {
    const content = typeof message?.content === 'string' ? message.content.length : JSON.stringify(message?.content || '').length;
    const toolCalls = Array.isArray(message?.toolCalls) ? JSON.stringify(message.toolCalls).length : 0;
    return content + toolCalls + 64;
  }

  function estimateSessionSnapshotWeight(snapshot) {
    const base = JSON.stringify({
      title: snapshot.title || '',
      mode: snapshot.mode || '',
      model: snapshot.model || '',
      agent: snapshot.agent || '',
      cwd: snapshot.cwd || '',
      updated: snapshot.updated || '',
    }).length;
    return base + (snapshot.messages || []).reduce((sum, message) => sum + estimateSessionMessageWeight(message), 0);
  }

  function normalizeSessionSnapshot(payload, options = {}) {
    const sessionId = payload.sessionId || payload.id || '';
    const messages = cloneMessages(payload.messages || []);
    const historyTotal = Number.isFinite(Number(payload.historyTotal))
      ? Math.max(0, Number(payload.historyTotal))
      : messages.length;
    const historyBaseIndex = Number.isFinite(Number(payload.historyBaseIndex))
      ? Math.max(0, Number(payload.historyBaseIndex))
      : Math.max(0, historyTotal - messages.length);
    return {
      sessionId,
      id: sessionId,
      messages,
      title: payload.title || '新会话',
      mode: payload.mode || 'yolo',
      model: payload.model || '',
      agent: normalizeAgent(payload.agent),
      pinnedAt: payload.pinnedAt || null,
      titleSource: payload.titleSource || null,
      createdFromKind: payload.createdFromKind || null,
      hasUnread: !!payload.hasUnread,
      cwd: payload.cwd || null,
      projectName: payload.projectName || '',
      oversized: !!payload.oversized,
      fileBytes: Number(payload.fileBytes || 0),
      totalCost: typeof payload.totalCost === 'number' ? payload.totalCost : 0,
      totalUsage: payload.totalUsage ? deepClone(payload.totalUsage) : null,
      updated: payload.updated || null,
      isRunning: !!payload.isRunning,
      waitingOnChildren: !!payload.waitingOnChildren,
      pendingReplyCount: Number(payload.pendingReplyCount || 0),
      readyReplyCount: Number(payload.readyReplyCount || 0),
      waitingReplyCount: Number(payload.waitingReplyCount || 0),
      failedReplyCount: Number(payload.failedReplyCount || 0),
      pendingReplies: Array.isArray(payload.pendingReplies) ? deepClone(payload.pendingReplies) : [],
      historyTotal,
      historyBaseIndex,
      historyPending: !!payload.historyPending,
      complete: options.complete !== undefined ? !!options.complete : !payload.historyPending,
    };
  }

  function touchSessionCache(sessionId) {
    const entry = sessionCache.get(sessionId);
    if (entry) entry.lastUsed = Date.now();
  }

  function invalidateSessionCache(sessionId) {
    if (!sessionId) return;
    sessionCache.delete(sessionId);
  }

  function pruneSessionCache() {
    let totalWeight = 0;
    for (const entry of sessionCache.values()) totalWeight += entry.weight || 0;
    while (sessionCache.size > SESSION_CACHE_LIMIT || totalWeight > SESSION_CACHE_MAX_WEIGHT) {
      let oldestId = null;
      let oldestTs = Infinity;
      for (const [sessionId, entry] of sessionCache) {
        if ((entry.lastUsed || 0) < oldestTs) {
          oldestTs = entry.lastUsed || 0;
          oldestId = sessionId;
        }
      }
      if (!oldestId) break;
      totalWeight -= sessionCache.get(oldestId)?.weight || 0;
      sessionCache.delete(oldestId);
    }
  }

  function cacheSessionSnapshot(snapshot) {
    if (!snapshot?.sessionId || !snapshot.complete) return;
    const cachedSnapshot = deepClone(snapshot);
    const weight = estimateSessionSnapshotWeight(cachedSnapshot);
    if (weight > SESSION_CACHE_MAX_WEIGHT) {
      invalidateSessionCache(cachedSnapshot.sessionId);
      return;
    }
    const meta = getSessionMeta(cachedSnapshot.sessionId);
    sessionCache.set(cachedSnapshot.sessionId, {
      snapshot: cachedSnapshot,
      version: cachedSnapshot.updated || null,
      meta: meta ? deepClone(meta) : null,
      weight,
      lastUsed: Date.now(),
    });
    pruneSessionCache();
  }

  function updateCachedSession(sessionId, updater) {
    const entry = sessionCache.get(sessionId);
    if (!entry) return;
    const nextSnapshot = deepClone(entry.snapshot);
    updater(nextSnapshot);
    entry.snapshot = nextSnapshot;
    entry.weight = estimateSessionSnapshotWeight(nextSnapshot);
    entry.lastUsed = Date.now();
    if (nextSnapshot.updated) entry.version = nextSnapshot.updated;
    pruneSessionCache();
  }

  function reconcileSessionCacheWithSessions() {
    const knownIds = new Set(sessions.map((session) => session.id));
    for (const [sessionId, entry] of sessionCache) {
      if (!knownIds.has(sessionId)) {
        sessionCache.delete(sessionId);
        continue;
      }
      const meta = getSessionMeta(sessionId);
      entry.meta = meta ? deepClone(meta) : null;
    }
  }

  function mergeSessionListSnapshot(snapshot) {
    if (!snapshot?.sessionId) return;
    const nextMeta = {
      id: snapshot.sessionId,
      sessionId: snapshot.sessionId,
      cwd: snapshot.cwd || '',
      projectName: snapshot.cwd ? getPathLeaf(snapshot.cwd) : snapshot.projectName || '',
      title: snapshot.title || '新会话',
      agent: normalizeAgent(snapshot.agent),
      updated: snapshot.updated || new Date().toISOString(),
      pinnedAt: snapshot.pinnedAt || null,
      titleSource: snapshot.titleSource || null,
      createdFromKind: snapshot.createdFromKind || null,
      hasUnread: !!snapshot.hasUnread,
      isRunning: !!snapshot.isRunning,
      waitingOnChildren: !!snapshot.waitingOnChildren,
      pendingReplyCount: Number(snapshot.pendingReplyCount || 0),
      readyReplyCount: Number(snapshot.readyReplyCount || 0),
      waitingReplyCount: Number(snapshot.waitingReplyCount || 0),
      failedReplyCount: Number(snapshot.failedReplyCount || 0),
      oversized: !!snapshot.oversized,
      fileBytes: Number(snapshot.fileBytes || 0),
    };
    let found = false;
    sessions = sessions.map((session) => {
      if (session.id !== snapshot.sessionId) return session;
      found = true;
      return {
        ...session,
        ...nextMeta,
        cwd: nextMeta.cwd || session.cwd || '',
        projectName: nextMeta.cwd ? getPathLeaf(nextMeta.cwd) : session.projectName || nextMeta.projectName || '',
        title: nextMeta.title || session.title,
        titleSource: nextMeta.titleSource || session.titleSource || null,
        createdFromKind: nextMeta.createdFromKind || session.createdFromKind || null,
      };
    });
    if (!found) {
      sessions = [nextMeta, ...sessions].sort(compareSessionUpdatedDesc);
    }
  }

  function getSessionCacheDisposition(sessionId) {
    const entry = sessionCache.get(sessionId);
    const meta = getSessionMeta(sessionId);
    if (!entry?.snapshot?.complete || !meta) return 'miss';
    if (entry.version === (meta.updated || null) && !meta.hasUnread && !meta.isRunning && !meta.waitingOnChildren) {
      return 'strong';
    }
    return 'weak';
  }

  function buildCachedSessionSnapshot(sessionId) {
    const entry = sessionCache.get(sessionId);
    if (!entry?.snapshot) return null;
    const snapshot = deepClone(entry.snapshot);
    const meta = getSessionMeta(sessionId) || entry.meta;
    if (meta) {
      snapshot.title = meta.title || snapshot.title;
      snapshot.agent = normalizeAgent(meta.agent || snapshot.agent);
      snapshot.hasUnread = !!meta.hasUnread;
      snapshot.updated = meta.updated || snapshot.updated;
      snapshot.pinnedAt = meta.pinnedAt || null;
      snapshot.isRunning = !!meta.isRunning;
      snapshot.waitingOnChildren = !!meta.waitingOnChildren;
      snapshot.pendingReplyCount = Number(meta.pendingReplyCount || 0);
      snapshot.readyReplyCount = Number(meta.readyReplyCount || 0);
      snapshot.waitingReplyCount = Number(meta.waitingReplyCount || 0);
      snapshot.failedReplyCount = Number(meta.failedReplyCount || 0);
    }
    return snapshot;
  }

  function formatFileSize(bytes) {
    const size = Number(bytes) || 0;
    if (size < 1024) return `${size}B`;
    if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)}KB`;
    return `${(size / (1024 * 1024)).toFixed(1)}MB`;
  }

  function normalizeBrowserPath(input) {
    return String(input || '')
      .replace(/\\/g, '/')
      .split('/')
      .filter((part) => part && part !== '.')
      .join('/');
  }

  function getPathLeaf(input) {
    const normalized = String(input || '').replace(/\\/g, '/').replace(/\/+$/, '');
    if (!normalized) return '';
    const parts = normalized.split('/');
    return parts[parts.length - 1] || normalized;
  }

  function getBrowserParentPath(currentPath) {
    const normalized = normalizeBrowserPath(currentPath);
    if (!normalized) return '';
    const parts = normalized.split('/');
    parts.pop();
    return parts.join('/');
  }

  function getBrowserDisplayPath(rootPath, currentPath) {
    const root = String(rootPath || '').replace(/\\/g, '/').replace(/\/+$/, '');
    const current = normalizeBrowserPath(currentPath);
    return current ? `${root}/${current}` : root;
  }

  function normalizeAbsoluteDisplayPath(input) {
    return String(input || '').trim().replace(/\\/g, '/').replace(/\/+$/, '');
  }

  function isAbsoluteDisplayPath(input) {
    const value = normalizeAbsoluteDisplayPath(input);
    return value.startsWith('/') || /^[A-Za-z]:\//.test(value);
  }

  function getWorkspaceRelativePath(input) {
    const filePath = normalizeAbsoluteDisplayPath(input);
    if (!filePath) return '';
    if (!isAbsoluteDisplayPath(filePath)) return normalizeBrowserPath(filePath);

    const rootPath = normalizeAbsoluteDisplayPath(currentCwd);
    if (!rootPath) return '';
    if (filePath === rootPath) return '';
    if (!filePath.startsWith(`${rootPath}/`)) return '';
    return normalizeBrowserPath(filePath.slice(rootPath.length + 1));
  }

  function parseLocalFileLinkHref(rawHref) {
    const raw = String(rawHref || '').trim();
    if (!raw || raw.startsWith('#')) return null;

    let decoded = raw;
    try { decoded = decodeURI(raw); } catch {}

    if (/^file:\/\//i.test(decoded)) {
      try { decoded = decodeURI(new URL(decoded).pathname || decoded); } catch {}
    } else if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(decoded) && !/^[A-Za-z]:[\\/]/.test(decoded)) {
      return null;
    }

    let line = 0;
    let hasLineSuffix = false;
    const hashMatch = decoded.match(/#L?(\d+)(?:\b|$)/i);
    if (hashMatch) {
      line = Number(hashMatch[1]) || 0;
      decoded = decoded.slice(0, hashMatch.index);
    }

    decoded = decoded.replace(/\\/g, '/');
    const lineMatch = decoded.match(/^(.+?):(\d+)(?::\d+)?$/);
    if (lineMatch) {
      decoded = lineMatch[1];
      line = Number(lineMatch[2]) || line;
      hasLineSuffix = true;
    }

    const filePath = normalizeAbsoluteDisplayPath(decoded);
    if (!filePath) return null;
    if (!isAbsoluteDisplayPath(filePath) && !/^\.{1,2}\//.test(filePath) && !filePath.includes('/') && !hasLineSuffix) return null;

    return { filePath, line };
  }

  function formatLocalFileLinkText(link, fallbackText) {
    const rawText = String(fallbackText || '').trim();
    const baseText = rawText && rawText !== link.filePath ? rawText : (getPathLeaf(link.filePath) || link.filePath);
    if (!link.line || new RegExp(`:${link.line}$`).test(baseText)) return baseText;
    return `${baseText}:${link.line}`;
  }

  async function fetchAuthJson(url, options = {}) {
    await ensureAuthenticatedWs();
    const response = await fetch(url, {
      ...options,
      headers: {
        ...(options.headers || {}),
        Authorization: `Bearer ${authToken}`,
      },
    });
    const rawText = await response.text();
    let data = null;
    try {
      data = rawText ? JSON.parse(rawText) : null;
    } catch {
      data = null;
    }
    if (response.status === 401) {
      throw new Error('登录状态已失效，请刷新页面后重新登录。');
    }
    if (!response.ok || data?.ok === false) {
      throw new Error(data?.message || `请求失败 (${response.status})`);
    }
    return data || {};
  }

  function updateReloadMcpButtonUI() {
    if (!reloadMcpBtn) return;
    const visible = !!currentSessionId && isCodexAppAgent(currentAgent);
    reloadMcpBtn.hidden = !visible;
    reloadMcpBtn.disabled = !visible || isReloadingMcp;
    reloadMcpBtn.textContent = isReloadingMcp ? '重载中' : '重载 MCP';
    reloadMcpBtn.setAttribute('aria-busy', isReloadingMcp ? 'true' : 'false');
  }

  function normalizeMcpStartupStatusPayload(payload) {
    if (!payload || typeof payload !== 'object') return null;
    if (payload.mcpStatus && typeof payload.mcpStatus === 'object') return payload.mcpStatus;
    if (payload.status && typeof payload.status === 'object') return payload.status;
    return payload;
  }

  function mcpStartupStatusToastText(status) {
    const summary = normalizeMcpStartupStatusPayload(status);
    if (!summary) return '已请求重载，等待状态';
    const server = String(summary.server || summary.name || 'ccweb').trim() || 'ccweb';
    const state = String(summary.status || 'unknown').trim().toLowerCase();
    const message = String(summary.message || '').trim();
    if (state === 'ready') return `${server} MCP 已启动`;
    if (state === 'failed') return `${server} MCP 启动失败${message ? `：${message}` : ''}`;
    if (state === 'cancelled' || state === 'canceled') return `${server} MCP 启动已取消${message ? `：${message}` : ''}`;
    if (state === 'starting') return `${server} MCP 正在启动`;
    if (state === 'pending' || state === 'unknown') return '已请求重载，等待状态';
    return `${server} MCP 状态：${state}`;
  }

  function rememberMcpStartupStatus(sessionId, status) {
    const summary = normalizeMcpStartupStatusPayload(status);
    if (!sessionId || !summary) return;
    updateCachedSession(sessionId, (snapshot) => {
      snapshot.codexAppMcpStartupStatus = deepClone(summary);
    });
  }

  function showMcpStartupStatusToast(status, sessionId = currentSessionId, options = {}) {
    const summary = normalizeMcpStartupStatusPayload(status);
    const text = mcpStartupStatusToastText(summary);
    const server = String(summary?.server || summary?.name || 'ccweb').trim() || 'ccweb';
    const state = String(summary?.status || 'pending').trim().toLowerCase() || 'pending';
    if (state === 'ready' && !options.notifyReady) return;
    if ((state === 'starting' || state === 'pending' || state === 'unknown') && !options.notifyPending) return;
    const stamp = state === 'failed' || state === 'cancelled' || state === 'canceled'
      ? String(summary?.message || text || '')
      : '';
    const cacheKey = sessionId || currentSessionId || 'global';
    const nextKey = `${server}|${state}|${stamp}`;
    if (mcpStartupToastKeys.get(cacheKey) === nextKey) return;
    mcpStartupToastKeys.set(cacheKey, nextKey);
    showToast(text, sessionId);
  }

  async function reloadCurrentMcpServers() {
    if (!currentSessionId || !isCodexAppAgent(currentAgent) || isReloadingMcp) return;
    isReloadingMcp = true;
    updateReloadMcpButtonUI();
    try {
      const data = await fetchAuthJson(`/api/sessions/${encodeURIComponent(currentSessionId)}/reload-mcp`, {
        method: 'POST',
      });
      rememberMcpStartupStatus(currentSessionId, data.mcpStatus);
      showMcpStartupStatusToast(data.mcpStatus || { status: 'pending' }, currentSessionId, {
        notifyReady: true,
        notifyPending: true,
      });
    } catch (err) {
      showToast(err?.message || '重载 MCP 失败');
    } finally {
      isReloadingMcp = false;
      updateReloadMcpButtonUI();
    }
  }

  function closeCodexAppUserInputModal(sendCancel = false) {
    if (!codexAppUserInputModal) return;
    const { overlay, escapeHandler, requestId, sessionId } = codexAppUserInputModal;
    if (escapeHandler) document.removeEventListener('keydown', escapeHandler);
    if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay);
    codexAppUserInputModal = null;
    if (sendCancel && requestId) {
      send({
        type: 'codex_app_user_input_response',
        action: 'cancel',
        sessionId,
        requestId,
        answers: {},
      });
    }
  }

  function codexAppApprovalPayloadText(payload) {
    if (payload === null || payload === undefined) return '';
    if (typeof payload === 'string') return payload;
    try {
      return JSON.stringify(payload, null, 2);
    } catch {
      return String(payload);
    }
  }

  function closeCodexAppApprovalModal(sendCancel = false) {
    if (!codexAppApprovalModal) return;
    const { overlay, escapeHandler, requestId, sessionId } = codexAppApprovalModal;
    if (escapeHandler) document.removeEventListener('keydown', escapeHandler);
    if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay);
    codexAppApprovalModal = null;
    if (sendCancel && requestId) {
      send({
        type: 'codex_app_approval_response',
        action: 'cancel',
        sessionId,
        requestId,
      });
    }
  }

  function submitCodexAppApproval(action) {
    if (!codexAppApprovalModal) return;
    const { requestId, sessionId } = codexAppApprovalModal;
    send({
      type: 'codex_app_approval_response',
      action,
      sessionId,
      requestId,
    });
    closeCodexAppApprovalModal(false);
  }

  function showCodexAppApprovalModal(msg) {
    closeCodexAppApprovalModal(true);
    const payloadText = codexAppApprovalPayloadText(msg.payload);
    const overlay = document.createElement('div');
    overlay.className = 'modal-overlay codex-approval-overlay';
    overlay.innerHTML = `
      <div class="modal-panel codex-approval-panel">
        <div class="modal-header">
          <span class="modal-title">${escapeHtml(msg.title || 'Codex App 请求审批')}</span>
          <button class="modal-close-btn" type="button" data-codex-approval-cancel>✕</button>
        </div>
        <div class="modal-body codex-approval-body">
          ${msg.summary ? `<div class="codex-approval-summary">${escapeHtml(msg.summary)}</div>` : ''}
          ${msg.reason ? `<div class="codex-approval-reason">${escapeHtml(msg.reason)}</div>` : ''}
          <div class="codex-approval-meta">
            <span>${escapeHtml(msg.approvalType || 'request')}</span>
            ${msg.itemId ? `<span>${escapeHtml(msg.itemId)}</span>` : ''}
          </div>
          ${payloadText ? `<pre class="codex-approval-payload">${escapeHtml(payloadText)}</pre>` : ''}
        </div>
        <div class="modal-footer codex-approval-footer">
          <button class="modal-btn-secondary" type="button" data-codex-approval-cancel>取消</button>
          <button class="modal-btn-secondary codex-approval-deny-btn" type="button" data-codex-approval-action="deny">拒绝</button>
          <button class="modal-btn-primary" type="button" data-codex-approval-action="approve">本次批准</button>
          ${msg.allowSessionScope ? '<button class="modal-btn-primary" type="button" data-codex-approval-action="approve_session">本会话批准</button>' : ''}
        </div>
      </div>
    `;
    document.body.appendChild(overlay);

    const escapeHandler = (e) => {
      if (e.key === 'Escape') closeCodexAppApprovalModal(true);
    };
    document.addEventListener('keydown', escapeHandler);

    codexAppApprovalModal = {
      overlay,
      requestId: msg.requestId || '',
      sessionId: msg.sessionId || '',
      escapeHandler,
    };

    overlay.querySelectorAll('[data-codex-approval-cancel]').forEach((button) => {
      button.addEventListener('click', () => closeCodexAppApprovalModal(true));
    });
    overlay.addEventListener('click', (e) => {
      if (e.target === overlay) closeCodexAppApprovalModal(true);
    });
    overlay.querySelectorAll('[data-codex-approval-action]').forEach((button) => {
      button.addEventListener('click', () => submitCodexAppApproval(button.dataset.codexApprovalAction || 'cancel'));
    });
    overlay.querySelector('[data-codex-approval-action="approve"]')?.focus();
  }

  function cssEscape(value) {
    if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(String(value || ''));
    return String(value || '').replace(/["\\]/g, '\\$&');
  }

  function collectCodexAppUserInputAnswers(panel, questions) {
    const answers = {};
    for (const question of questions) {
      const id = String(question?.id || '').trim();
      if (!id) continue;
      const escapedId = cssEscape(id);
      const checked = panel.querySelector(`input[name="codex-ui-${escapedId}"]:checked`);
      const values = [];
      if (checked) {
        if (checked.value === '__other__') {
          const input = panel.querySelector(`[data-codex-ui-other="${escapedId}"]`);
          const text = String(input?.value || '').trim();
          if (text) values.push(text);
        } else {
          values.push(checked.value);
        }
      } else {
        const input = panel.querySelector(`[data-codex-ui-other="${escapedId}"]`);
        const text = String(input?.value || '').trim();
        if (text) values.push(text);
      }
      answers[id] = { answers: values };
    }
    return answers;
  }

  function renderCodexAppQuestion(question, index) {
    const id = String(question?.id || `q${index}`);
    const options = Array.isArray(question?.options) ? question.options : [];
    const hasOther = !!question?.isOther || options.length === 0;
    const inputType = question?.isSecret ? 'password' : 'text';
    const optionHtml = options.map((option, optionIndex) => {
      const value = String(option?.label || `选项 ${optionIndex + 1}`);
      return `
        <label class="codex-user-input-option">
          <input type="radio" name="codex-ui-${escapeHtml(id)}" value="${escapeHtml(value)}"${optionIndex === 0 && !hasOther ? ' checked' : ''}>
          <span class="codex-user-input-option-copy">
            <span class="codex-user-input-option-label">${escapeHtml(value)}</span>
            ${option?.description ? `<span class="codex-user-input-option-desc">${escapeHtml(option.description)}</span>` : ''}
          </span>
        </label>
      `;
    }).join('');
    const otherHtml = hasOther ? `
      <label class="codex-user-input-option codex-user-input-other-option">
        ${options.length > 0 ? `<input type="radio" name="codex-ui-${escapeHtml(id)}" value="__other__">` : ''}
        <span class="codex-user-input-option-copy">
          <span class="codex-user-input-option-label">${options.length > 0 ? '其他' : '回答'}</span>
          <input class="codex-user-input-text" type="${inputType}" data-codex-ui-other="${escapeHtml(id)}" autocomplete="off">
        </span>
      </label>
    ` : '';

    return `
      <section class="codex-user-input-question">
        <div class="codex-user-input-kicker">${escapeHtml(question?.header || `问题 ${index + 1}`)}</div>
        <div class="codex-user-input-prompt">${escapeHtml(question?.question || '请选择一个答案。')}</div>
        <div class="codex-user-input-options">
          ${optionHtml}
          ${otherHtml}
        </div>
      </section>
    `;
  }

  function showCodexAppUserInputModal(msg) {
    closeCodexAppUserInputModal(true);
    const questions = Array.isArray(msg.questions) ? msg.questions : [];
    const overlay = document.createElement('div');
    overlay.className = 'modal-overlay codex-user-input-overlay';
    overlay.innerHTML = `
      <div class="modal-panel codex-user-input-panel">
        <div class="modal-header">
          <span class="modal-title">Codex App 需要输入</span>
          <button class="modal-close-btn" type="button" data-codex-ui-cancel>✕</button>
        </div>
        <div class="modal-body codex-user-input-body">
          ${questions.length > 0
            ? questions.map((question, index) => renderCodexAppQuestion(question, index)).join('')
            : '<div class="modal-empty">Codex App 没有提供可回答的问题。</div>'}
        </div>
        <div class="modal-footer">
          <button class="modal-btn-secondary" type="button" data-codex-ui-cancel>取消</button>
          <button class="modal-btn-primary" type="button" data-codex-ui-submit>提交</button>
        </div>
      </div>
    `;
    document.body.appendChild(overlay);

    const panel = overlay.querySelector('.codex-user-input-panel');
    const escapeHandler = (e) => {
      if (e.key === 'Escape') closeCodexAppUserInputModal(true);
    };
    document.addEventListener('keydown', escapeHandler);

    codexAppUserInputModal = {
      overlay,
      requestId: msg.requestId || '',
      sessionId: msg.sessionId || '',
      escapeHandler,
    };

    overlay.querySelectorAll('[data-codex-ui-cancel]').forEach((button) => {
      button.addEventListener('click', () => closeCodexAppUserInputModal(true));
    });
    overlay.addEventListener('click', (e) => {
      if (e.target === overlay) closeCodexAppUserInputModal(true);
    });
    overlay.querySelector('[data-codex-ui-submit]')?.addEventListener('click', () => {
      send({
        type: 'codex_app_user_input_response',
        action: 'submit',
        sessionId: msg.sessionId,
        requestId: msg.requestId,
        answers: collectCodexAppUserInputAnswers(panel, questions),
      });
      closeCodexAppUserInputModal(false);
    });

    panel.querySelectorAll('.codex-user-input-text').forEach((input) => {
      input.addEventListener('focus', () => {
        const radio = input.closest('.codex-user-input-option')?.querySelector('input[type="radio"]');
        if (radio) radio.checked = true;
      });
    });
    panel.querySelector('input, button')?.focus();
  }

  function ccwebPromptStatusLabel(status) {
    if (status === 'submitted') return '已提交';
    if (status === 'cancelled') return '已取消';
    return '待回答';
  }

  function ccwebPromptRecommendedOption(question) {
    const options = Array.isArray(question?.options) ? question.options : [];
    return options.find((option) => option?.recommended) || options[0] || null;
  }

  function getCcwebPromptViewMode() {
    return localStorage.getItem(CCWEB_PROMPT_VIEW_MODE_STORAGE_KEY) === 'tabs' ? 'tabs' : 'cards';
  }

  function setCcwebPromptActiveQuestion(card, index) {
    if (!card) return;
    const questions = Array.from(card.querySelectorAll('.ccweb-prompt-question'));
    if (questions.length === 0) return;
    const activeIndex = Math.min(Math.max(Number(index) || 0, 0), questions.length - 1);
    card.dataset.activeQuestionIndex = String(activeIndex);
    questions.forEach((questionEl, questionIndex) => {
      const isActive = questionIndex === activeIndex;
      questionEl.classList.toggle('is-active', isActive);
      questionEl.setAttribute('aria-hidden', isActive ? 'false' : 'true');
    });
    card.querySelectorAll('.ccweb-prompt-tab').forEach((tab, tabIndex) => {
      const isActive = tabIndex === activeIndex;
      tab.classList.toggle('is-active', isActive);
      tab.setAttribute('aria-selected', isActive ? 'true' : 'false');
      tab.tabIndex = isActive ? 0 : -1;
    });
    const counter = card.querySelector('.ccweb-prompt-tab-counter');
    if (counter) counter.textContent = `问题 ${activeIndex + 1} / ${questions.length}`;
    const prev = card.querySelector('[data-ccweb-prompt-prev]');
    const next = card.querySelector('[data-ccweb-prompt-next]');
    if (prev) prev.disabled = activeIndex <= 0;
    if (next) next.disabled = activeIndex >= questions.length - 1;
  }

  function setCcwebPromptViewMode(card, mode) {
    if (!card) return;
    const normalized = mode === 'tabs' ? 'tabs' : 'cards';
    card.dataset.viewMode = normalized;
    card.querySelectorAll('.ccweb-prompt-view-btn').forEach((button) => {
      const isActive = button.dataset.viewMode === normalized;
      button.classList.toggle('is-active', isActive);
      button.setAttribute('aria-pressed', isActive ? 'true' : 'false');
    });
    if (normalized === 'tabs') {
      setCcwebPromptActiveQuestion(card, Number(card.dataset.activeQuestionIndex || 0));
    } else {
      card.querySelectorAll('.ccweb-prompt-question').forEach((questionEl) => {
        questionEl.setAttribute('aria-hidden', 'false');
      });
    }
  }

  function createCcwebPromptViewControls(card, questions) {
    const switcher = document.createElement('div');
    switcher.className = 'ccweb-prompt-view-switcher';
    switcher.setAttribute('aria-label', '表单显示方式');
    [
      { mode: 'cards', label: '▦', title: '卡片视图' },
      { mode: 'tabs', label: '▤', title: '页签视图' },
    ].forEach((item) => {
      const button = document.createElement('button');
      button.type = 'button';
      button.className = 'ccweb-prompt-view-btn';
      button.dataset.viewMode = item.mode;
      button.textContent = item.label;
      button.title = item.title;
      button.setAttribute('aria-label', item.title);
      button.addEventListener('click', () => {
        localStorage.setItem(CCWEB_PROMPT_VIEW_MODE_STORAGE_KEY, item.mode);
        setCcwebPromptViewMode(card, item.mode);
      });
      switcher.appendChild(button);
    });
    return switcher;
  }

  function createCcwebPromptTabs(card, questions) {
    const controls = document.createElement('div');
    controls.className = 'ccweb-prompt-view-controls';

    const tabs = document.createElement('div');
    tabs.className = 'ccweb-prompt-tabs';
    tabs.setAttribute('role', 'tablist');
    questions.forEach((question, index) => {
      const tab = document.createElement('button');
      tab.type = 'button';
      tab.className = 'ccweb-prompt-tab';
      tab.setAttribute('role', 'tab');
      tab.textContent = question.title || `问题 ${index + 1}`;
      tab.addEventListener('click', () => setCcwebPromptActiveQuestion(card, index));
      tabs.appendChild(tab);
    });

    controls.append(tabs);
    return controls;
  }

  function createCcwebPromptTabNav(card, questions) {
    const nav = document.createElement('div');
    nav.className = 'ccweb-prompt-tab-nav';
    if (!Array.isArray(questions) || questions.length <= 1) return nav;
    const prev = document.createElement('button');
    prev.type = 'button';
    prev.className = 'ccweb-prompt-tab-nav-btn';
    prev.dataset.ccwebPromptPrev = '1';
    prev.textContent = '上一个';
    prev.addEventListener('click', () => setCcwebPromptActiveQuestion(card, Number(card.dataset.activeQuestionIndex || 0) - 1));
    const counter = document.createElement('span');
    counter.className = 'ccweb-prompt-tab-counter';
    const next = document.createElement('button');
    next.type = 'button';
    next.className = 'ccweb-prompt-tab-nav-btn';
    next.dataset.ccwebPromptNext = '1';
    next.textContent = '下一个';
    next.addEventListener('click', () => setCcwebPromptActiveQuestion(card, Number(card.dataset.activeQuestionIndex || 0) + 1));
    nav.append(prev, counter, next);
    return nav;
  }

  function setCcwebPromptError(card, message) {
    const error = card.querySelector('.ccweb-prompt-error');
    if (!error) return;
    error.textContent = message || '';
    error.hidden = !message;
  }

  function updateCcwebPromptAnswerFromSelection(questionEl, question) {
    const textarea = questionEl.querySelector('.ccweb-prompt-answer');
    if (!textarea) return;
    const previousSelectionText = textarea.dataset.ccwebPromptSelectionText || '';
    const selectedIds = Array.from(questionEl.querySelectorAll('.ccweb-prompt-option.is-selected'))
      .map((button) => button.dataset.optionId || '')
      .filter(Boolean);
    const selectedOptions = (question.options || []).filter((option) => selectedIds.includes(option.id));
    const answerText = selectedOptions.map((option) => option.answerText || option.label || '').filter(Boolean).join('\n');
    if (answerText) {
      textarea.value = answerText;
      textarea.dataset.ccwebPromptSelectionText = answerText;
      return;
    }
    if (previousSelectionText && textarea.value === previousSelectionText) textarea.value = '';
    delete textarea.dataset.ccwebPromptSelectionText;
  }

  function syncCcwebPromptOptionState(questionEl) {
    questionEl.querySelectorAll('.ccweb-prompt-option').forEach((button) => {
      button.setAttribute('aria-pressed', button.classList.contains('is-selected') ? 'true' : 'false');
    });
  }

  function selectCcwebPromptOption(questionEl, question, optionId) {
    const buttons = Array.from(questionEl.querySelectorAll('.ccweb-prompt-option'));
    if (question.selectionMode === 'multi') {
      buttons.forEach((button) => {
        if (button.dataset.optionId === optionId) button.classList.toggle('is-selected');
      });
    } else {
      const shouldDeselect = buttons.some((button) => button.dataset.optionId === optionId && button.classList.contains('is-selected'));
      buttons.forEach((button) => {
        button.classList.toggle('is-selected', !shouldDeselect && button.dataset.optionId === optionId);
      });
    }
    syncCcwebPromptOptionState(questionEl);
    updateCcwebPromptAnswerFromSelection(questionEl, question);
  }

  function clearCcwebPromptSelection(questionEl, question) {
    questionEl.querySelectorAll('.ccweb-prompt-option.is-selected').forEach((button) => {
      button.classList.remove('is-selected');
    });
    syncCcwebPromptOptionState(questionEl);
    updateCcwebPromptAnswerFromSelection(questionEl, question);
  }

  function createCcwebPromptQuestionElement(question, index, prompt) {
    const questionEl = document.createElement('section');
    questionEl.className = 'ccweb-prompt-question';
    questionEl.dataset.questionId = question.id || `question_${index + 1}`;

    const head = document.createElement('div');
    head.className = 'ccweb-prompt-question-head';
    const title = document.createElement('div');
    title.className = 'ccweb-prompt-question-title';
    title.textContent = question.title || `问题 ${index + 1}`;
    head.appendChild(title);
    if (question.required !== false && prompt.status !== 'submitted') {
      const required = document.createElement('span');
      required.className = 'ccweb-prompt-required';
      required.textContent = '必答';
      head.appendChild(required);
    }
    questionEl.appendChild(head);

    if (question.question) {
      const body = document.createElement('div');
      body.className = 'ccweb-prompt-question-body';
      body.textContent = question.question;
      questionEl.appendChild(body);
    }

    if (prompt.status === 'submitted') {
      const answer = prompt.answers?.[question.id] || {};
      if (Array.isArray(answer.selectedOptionLabels) && answer.selectedOptionLabels.length > 0) {
        const selected = document.createElement('div');
        selected.className = 'ccweb-prompt-selected-readonly';
        selected.textContent = `选择：${answer.selectedOptionLabels.join('，')}`;
        questionEl.appendChild(selected);
      }
      const answerText = document.createElement('div');
      answerText.className = 'ccweb-prompt-answer-readonly';
      answerText.textContent = answer.answerText || '（未填写答案）';
      questionEl.appendChild(answerText);
      return questionEl;
    }

    const options = Array.isArray(question.options) ? question.options : [];
    if (options.length > 0 && question.selectionMode !== 'none') {
      const optionList = document.createElement('div');
      optionList.className = 'ccweb-prompt-options';
      options.forEach((option) => {
        const button = document.createElement('button');
        button.type = 'button';
        button.className = 'ccweb-prompt-option';
        button.dataset.optionId = option.id || '';
        button.setAttribute('aria-pressed', 'false');
        const label = document.createElement('span');
        label.className = 'ccweb-prompt-option-label';
        label.textContent = option.label || option.id || '选项';
        button.appendChild(label);
        if (option.recommended) {
          const badge = document.createElement('span');
          badge.className = 'ccweb-prompt-option-badge';
          badge.textContent = '推荐';
          button.appendChild(badge);
        }
        if (option.description) {
          const desc = document.createElement('span');
          desc.className = 'ccweb-prompt-option-desc';
          desc.textContent = option.description;
          button.appendChild(desc);
        }
        button.addEventListener('click', () => selectCcwebPromptOption(questionEl, question, option.id));
        optionList.appendChild(button);
      });
      const clearChoice = document.createElement('button');
      clearChoice.type = 'button';
      clearChoice.className = 'ccweb-prompt-clear-choice';
      clearChoice.textContent = '不选择';
      clearChoice.addEventListener('click', () => clearCcwebPromptSelection(questionEl, question));
      optionList.appendChild(clearChoice);
      questionEl.appendChild(optionList);
    }

    const answer = document.createElement('textarea');
    answer.className = 'ccweb-prompt-answer';
    answer.rows = 4;
    answer.placeholder = question.answerPlaceholder || '填写你的答案...';
    answer.value = question.defaultAnswer || '';
    answer.addEventListener('input', () => {
      delete answer.dataset.ccwebPromptSelectionText;
    });
    questionEl.appendChild(answer);

    const recommended = ccwebPromptRecommendedOption(question);
    if (recommended?.recommended) {
      selectCcwebPromptOption(questionEl, question, recommended.id);
    }

    return questionEl;
  }

  function collectCcwebPromptAnswers(card, prompt) {
    const answers = {};
    for (const question of prompt.questions || []) {
      const escapedId = cssEscape(question.id || '');
      const questionEl = card.querySelector(`.ccweb-prompt-question[data-question-id="${escapedId}"]`);
      if (!questionEl) continue;
      const selectedOptionIds = Array.from(questionEl.querySelectorAll('.ccweb-prompt-option.is-selected'))
        .map((button) => button.dataset.optionId || '')
        .filter(Boolean);
      const answerText = String(questionEl.querySelector('.ccweb-prompt-answer')?.value || '').trim();
      if (question.required !== false && !answerText) {
        return { ok: false, message: `请填写「${question.title || question.id}」的答案。` };
      }
      answers[question.id] = { selectedOptionIds, answerText };
    }
    return { ok: true, answers };
  }

  function createCcwebPromptElement(prompt, meta = {}) {
    const card = document.createElement('section');
    const promptStatus = prompt?.status || 'pending';
    const questions = Array.isArray(prompt?.questions) ? prompt.questions : [];
    card.className = 'ccweb-prompt-card';
    card.dataset.promptId = prompt?.id || '';
    card.dataset.status = promptStatus;
    card.dataset.viewMode = 'cards';

    const header = document.createElement('div');
    header.className = 'ccweb-prompt-header';
    const titleWrap = document.createElement('div');
    titleWrap.className = 'ccweb-prompt-title-wrap';
    const title = document.createElement('div');
    title.className = 'ccweb-prompt-title';
    title.textContent = prompt?.title || '需要用户确认';
    titleWrap.appendChild(title);
    header.appendChild(titleWrap);
    const headerActions = document.createElement('div');
    headerActions.className = 'ccweb-prompt-header-actions';
    const status = document.createElement('span');
    status.className = 'ccweb-prompt-status';
    status.textContent = promptStatus === 'pending' ? '●' : ccwebPromptStatusLabel(prompt?.status || 'pending');
    status.title = ccwebPromptStatusLabel(prompt?.status || 'pending');
    status.setAttribute('aria-label', ccwebPromptStatusLabel(prompt?.status || 'pending'));
    headerActions.appendChild(status);
    if (promptStatus === 'pending' && questions.length > 1) {
      headerActions.appendChild(createCcwebPromptViewControls(card, questions));
    }
    header.appendChild(headerActions);
    card.appendChild(header);

    if (prompt?.description) {
      const desc = document.createElement('div');
      desc.className = 'ccweb-prompt-desc';
      desc.textContent = prompt.description;
      card.appendChild(desc);
    }

    if (promptStatus === 'pending' && questions.length > 1) {
      card.appendChild(createCcwebPromptTabs(card, questions));
    }

    const questionsWrap = document.createElement('div');
    questionsWrap.className = 'ccweb-prompt-questions';
    questions.forEach((question, index) => {
      questionsWrap.appendChild(createCcwebPromptQuestionElement(question, index, prompt));
    });
    card.appendChild(questionsWrap);

    const error = document.createElement('div');
    error.className = 'ccweb-prompt-error';
    error.hidden = true;
    card.appendChild(error);

    if ((prompt?.status || 'pending') === 'pending') {
      const footer = document.createElement('div');
      footer.className = 'ccweb-prompt-footer';
      if (questions.length > 1) {
        footer.appendChild(createCcwebPromptTabNav(card, questions));
      }
      const footerActions = document.createElement('div');
      footerActions.className = 'ccweb-prompt-footer-actions';
      const fillRecommended = document.createElement('button');
      fillRecommended.type = 'button';
      fillRecommended.className = 'ccweb-prompt-secondary';
      fillRecommended.textContent = '填入推荐';
      fillRecommended.addEventListener('click', () => {
        questions.forEach((question) => {
          const option = ccwebPromptRecommendedOption(question);
          if (!option) return;
          const questionEl = card.querySelector(`.ccweb-prompt-question[data-question-id="${cssEscape(question.id || '')}"]`);
          if (questionEl) selectCcwebPromptOption(questionEl, question, option.id);
        });
      });
      footerActions.appendChild(fillRecommended);

      const submit = document.createElement('button');
      submit.type = 'button';
      submit.className = 'ccweb-prompt-submit';
      submit.textContent = '提交全部';
      submit.addEventListener('click', () => {
        const collected = collectCcwebPromptAnswers(card, prompt);
        if (!collected.ok) {
          setCcwebPromptError(card, collected.message);
          return;
        }
        setCcwebPromptError(card, '');
        submit.disabled = true;
        submit.textContent = '提交中';
        send({
          type: 'ccweb_prompt_user_response',
          sessionId: meta.sessionId || currentSessionId,
          promptId: prompt.id,
          answers: collected.answers,
        });
      });
      footerActions.appendChild(submit);
      footer.appendChild(footerActions);
      card.appendChild(footer);
    }

    if (promptStatus === 'pending' && questions.length > 1) {
      setCcwebPromptActiveQuestion(card, 0);
      setCcwebPromptViewMode(card, getCcwebPromptViewMode());
    }

    return card;
  }

  function updateCcwebPromptMessageInSnapshot(snapshot, prompt) {
    if (!snapshot || !Array.isArray(snapshot.messages) || !prompt?.id) return;
    for (const message of snapshot.messages) {
      if (message?.ccwebPrompt?.id === prompt.id) {
        message.ccwebPrompt = deepClone(prompt);
      }
    }
  }

  function removeCcwebPromptMessageFromSnapshot(snapshot, promptId) {
    if (!snapshot || !Array.isArray(snapshot.messages) || !promptId) return;
    snapshot.messages = snapshot.messages.filter((message) => message?.ccwebPrompt?.id !== promptId);
  }

  function removeCcwebPromptMessageFromDom(promptId) {
    if (!promptId) return 0;
    let removed = 0;
    document.querySelectorAll(`.ccweb-prompt-card[data-prompt-id="${cssEscape(promptId)}"]`).forEach((card) => {
      const messageEl = card.closest('.msg');
      if (messageEl?.parentNode) {
        messageEl.remove();
      } else {
        card.remove();
      }
      removed += 1;
    });
    if (removed > 0) {
      updateUserOutlinePanel();
      updateScrollbar();
    }
    return removed;
  }

  function applyCcwebPromptUserUpdate(msg) {
    if (msg.sessionId && msg.prompt) {
      updateCachedSession(msg.sessionId, (snapshot) => updateCcwebPromptMessageInSnapshot(snapshot, msg.prompt));
    }
    if (msg.sessionId !== currentSessionId || !msg.prompt?.id) return;
    document.querySelectorAll(`.ccweb-prompt-card[data-prompt-id="${cssEscape(msg.prompt.id)}"]`).forEach((card) => {
      card.replaceWith(createCcwebPromptElement(msg.prompt, { sessionId: msg.sessionId }));
    });
    renderPendingCcwebPrompts({ scroll: false });
  }

  function applyCcwebPromptUserRemove(msg) {
    const promptId = msg.promptId || msg.prompt?.id || '';
    if (msg.sessionId && promptId) {
      updateCachedSession(msg.sessionId, (snapshot) => removeCcwebPromptMessageFromSnapshot(snapshot, promptId));
    }
    if (msg.sessionId !== currentSessionId || !promptId) return;
    removeCcwebPromptMessageFromDom(promptId);
    renderPendingCcwebPrompts({ scroll: false });
  }

  function closeDirectoryPicker() {
    if (!directoryPickerState) return;
    const { overlay, escapeHandler } = directoryPickerState;
    if (escapeHandler) document.removeEventListener('keydown', escapeHandler);
    if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay);
    directoryPickerState = null;
  }

  function setDirectoryPickerStatus(message, type = '') {
    if (!directoryPickerState?.statusEl) return;
    directoryPickerState.statusEl.textContent = message || '';
    directoryPickerState.statusEl.dataset.state = type || '';
  }

  function updateDirectoryPickerPathBar() {
    if (!directoryPickerState?.pathEl) return;
    const displayPath = directoryPickerState.currentPath || directoryPickerState.defaultPath || '';
    directoryPickerState.pathEl.textContent = displayPath;
    directoryPickerState.pathEl.title = displayPath;
    directoryPickerState.upBtn.disabled = !directoryPickerState.parentPath;
    directoryPickerState.chooseBtn.disabled = !displayPath;
  }

  function renderDirectoryPickerEntries(entries) {
    if (!directoryPickerState?.listEl) return;
    const safeEntries = Array.isArray(entries) ? entries : [];
    if (safeEntries.length === 0) {
      directoryPickerState.listEl.innerHTML = '<div class="modal-empty">这个目录里没有可进入的子目录，直接使用当前目录也可以。</div>';
      return;
    }

    directoryPickerState.listEl.innerHTML = safeEntries.map((entry) => {
      const metaParts = [entry.symlink ? '链接目录' : '目录'];
      if (entry.updatedAt) metaParts.push(timeAgo(entry.updatedAt));
      return `
        <button
          class="file-browser-item directory"
          type="button"
          data-path="${escapeHtml(entry.path || '')}"
        >
          <span class="file-browser-item-icon" aria-hidden="true">DIR</span>
          <span class="file-browser-item-copy">
            <span class="file-browser-item-name">${escapeHtml(entry.name || getPathLeaf(entry.path) || '')}</span>
            <span class="file-browser-item-meta">${escapeHtml(metaParts.join(' · '))}</span>
          </span>
        </button>
      `;
    }).join('');

    directoryPickerState.listEl.querySelectorAll('.file-browser-item').forEach((button) => {
      button.addEventListener('click', () => {
        const targetPath = button.dataset.path || '';
        if (targetPath) loadDirectoryPickerDirectory(targetPath);
      });
    });
  }

  async function loadDirectoryPickerDirectory(targetPath, options = {}) {
    if (!directoryPickerState) return;
    const state = directoryPickerState;
    const requestId = ++state.requestId;
    state.listEl.innerHTML = '<div class="modal-loading">正在读取目录…</div>';
    setDirectoryPickerStatus('正在读取目录…');

    try {
      const data = await fetchAuthJson(`/api/fs/directories?path=${encodeURIComponent(targetPath || '')}`);
      if (!directoryPickerState || state !== directoryPickerState || requestId !== state.requestId) return;
      state.currentPath = data.currentPath || state.currentPath;
      state.parentPath = data.parentPath || '';
      state.defaultPath = data.defaultPath || state.defaultPath;
      updateDirectoryPickerPathBar();
      renderDirectoryPickerEntries(data.entries || []);
      const statusParts = [];
      if (data.truncated) statusParts.push(`目录较大，仅显示前 ${data.entryLimit} 项`);
      statusParts.push(`当前共 ${Array.isArray(data.entries) ? data.entries.length : 0} 个子目录`);
      setDirectoryPickerStatus(statusParts.join(' · '));
    } catch (err) {
      if (!directoryPickerState || state !== directoryPickerState || requestId !== state.requestId) return;
      if (options.allowFallback !== false && targetPath) {
        loadDirectoryPickerDirectory('', { allowFallback: false });
        return;
      }
      state.listEl.innerHTML = `<div class="modal-empty">${escapeHtml(err.message || '目录读取失败')}</div>`;
      setDirectoryPickerStatus(err.message || '目录读取失败', 'error');
    }
  }

  function showDirectoryPicker(options = {}) {
    closeDirectoryPicker();

    const overlay = document.createElement('div');
    overlay.className = 'modal-overlay';
    overlay.id = 'directory-picker-overlay';
    overlay.innerHTML = `
      <div class="modal-panel modal-panel-wide directory-picker-panel">
        <div class="modal-header">
          <span class="modal-title">${escapeHtml(options.title || '选择工作目录')}</span>
          <button class="modal-close-btn" type="button" data-picker-close>✕</button>
        </div>
        <div class="modal-body file-browser-body">
          <div class="file-browser-toolbar">
            <button class="file-browser-toolbar-btn" type="button" data-picker-up>上一级</button>
            <button class="file-browser-toolbar-btn" type="button" data-picker-refresh>刷新</button>
            <div class="file-browser-path" data-picker-path></div>
          </div>
          <div class="file-browser-status" data-picker-status>正在读取目录…</div>
          <div class="file-browser-list directory-picker-list" data-picker-list></div>
        </div>
        <div class="modal-footer">
          <button class="modal-btn-secondary" type="button" data-picker-cancel>取消</button>
          <button class="modal-btn-primary" type="button" data-picker-choose disabled>使用当前目录</button>
        </div>
      </div>
    `;

    document.body.appendChild(overlay);

    directoryPickerState = {
      overlay,
      pathEl: overlay.querySelector('[data-picker-path]'),
      statusEl: overlay.querySelector('[data-picker-status]'),
      listEl: overlay.querySelector('[data-picker-list]'),
      upBtn: overlay.querySelector('[data-picker-up]'),
      refreshBtn: overlay.querySelector('[data-picker-refresh]'),
      chooseBtn: overlay.querySelector('[data-picker-choose]'),
      currentPath: '',
      parentPath: '',
      defaultPath: '',
      requestId: 0,
      onChoose: typeof options.onChoose === 'function' ? options.onChoose : null,
      escapeHandler: null,
    };

    directoryPickerState.escapeHandler = (e) => {
      if (e.key === 'Escape') closeDirectoryPicker();
    };
    document.addEventListener('keydown', directoryPickerState.escapeHandler);

    const closeButtons = overlay.querySelectorAll('[data-picker-close], [data-picker-cancel]');
    closeButtons.forEach((button) => button.addEventListener('click', closeDirectoryPicker));
    overlay.addEventListener('click', (e) => {
      if (e.target === overlay) closeDirectoryPicker();
    });
    directoryPickerState.upBtn.addEventListener('click', () => {
      if (directoryPickerState?.parentPath) loadDirectoryPickerDirectory(directoryPickerState.parentPath, { allowFallback: false });
    });
    directoryPickerState.refreshBtn.addEventListener('click', () => {
      loadDirectoryPickerDirectory(directoryPickerState?.currentPath || '', { allowFallback: false });
    });
    directoryPickerState.chooseBtn.addEventListener('click', () => {
      const selectedPath = directoryPickerState?.currentPath || directoryPickerState?.defaultPath || '';
      const onChoose = directoryPickerState?.onChoose;
      closeDirectoryPicker();
      if (selectedPath && typeof onChoose === 'function') onChoose(selectedPath);
    });

    updateDirectoryPickerPathBar();
    loadDirectoryPickerDirectory(String(options.initialPath || '').trim(), { allowFallback: true });
  }

  function closeFileBrowser() {
    if (!fileBrowserState) return;
    const { overlay, escapeHandler } = fileBrowserState;
    if (escapeHandler) document.removeEventListener('keydown', escapeHandler);
    if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay);
    fileBrowserState = null;
  }

  function setFileBrowserStatus(message, type = '') {
    if (!fileBrowserState?.statusEl) return;
    fileBrowserState.statusEl.textContent = message || '';
    fileBrowserState.statusEl.dataset.state = type || '';
  }

  function setFileBrowserPreviewMode(active) {
    if (!fileBrowserState?.panel) return;
    fileBrowserState.panel.classList.toggle('preview-active', !!active);
  }

  function syncFileBrowserSelection() {
    if (!fileBrowserState?.listEl) return;
    fileBrowserState.listEl.querySelectorAll('.file-browser-item').forEach((button) => {
      button.classList.toggle(
        'active',
        button.dataset.kind === 'file' && button.dataset.path === fileBrowserState.selectedFilePath
      );
    });
  }

  function renderFileBrowserPreviewEmpty(title, message) {
    if (!fileBrowserState) return;
    fileBrowserState.previewTitleEl.textContent = title || '文件预览';
    fileBrowserState.previewMetaEl.textContent = message || '选择一个文本文件查看内容';
    fileBrowserState.previewEmptyEl.textContent = message || '选择一个文本文件查看内容';
    fileBrowserState.previewEmptyEl.hidden = false;
    fileBrowserState.previewCodeEl.hidden = true;
    fileBrowserState.previewCodeEl.textContent = '';
  }

  function renderFileBrowserPreviewLoading(name) {
    if (!fileBrowserState) return;
    fileBrowserState.previewTitleEl.textContent = name || '文件预览';
    fileBrowserState.previewMetaEl.textContent = '正在读取文件内容…';
    fileBrowserState.previewEmptyEl.textContent = '正在读取文件内容…';
    fileBrowserState.previewEmptyEl.hidden = false;
    fileBrowserState.previewCodeEl.hidden = true;
    fileBrowserState.previewCodeEl.textContent = '';
  }

  function updateFileBrowserPathBar() {
    if (!fileBrowserState) return;
    const displayPath = getBrowserDisplayPath(fileBrowserState.rootPath, fileBrowserState.currentPath);
    fileBrowserState.pathEl.textContent = displayPath;
    fileBrowserState.pathEl.title = displayPath;
    fileBrowserState.upBtn.disabled = !fileBrowserState.currentPath;
  }

  function renderFileBrowserDirectory(entries) {
    if (!fileBrowserState) return;
    const state = fileBrowserState;
    const safeEntries = Array.isArray(entries) ? entries : [];

    if (safeEntries.length === 0) {
      state.listEl.innerHTML = '<div class="modal-empty">这个目录里还没有可显示的文件</div>';
      return;
    }

    state.listEl.innerHTML = safeEntries.map((entry) => {
      const metaParts = [];
      if (entry.kind === 'directory') {
        metaParts.push(entry.symlink ? '链接目录' : '目录');
      } else {
        metaParts.push(entry.previewableHint ? '文本' : '文件');
        if (entry.size >= 0) metaParts.push(formatFileSize(entry.size));
      }
      if (entry.updatedAt) metaParts.push(timeAgo(entry.updatedAt));
      return `
        <button
          class="file-browser-item${entry.kind === 'directory' ? ' directory' : ''}"
          type="button"
          data-kind="${escapeHtml(entry.kind)}"
          data-path="${escapeHtml(entry.path || '')}"
        >
          <span class="file-browser-item-icon" aria-hidden="true">${entry.kind === 'directory' ? 'DIR' : (entry.previewableHint ? 'TXT' : 'FILE')}</span>
          <span class="file-browser-item-copy">
            <span class="file-browser-item-name">${escapeHtml(entry.name || '')}</span>
            <span class="file-browser-item-meta">${escapeHtml(metaParts.join(' · '))}</span>
          </span>
        </button>
      `;
    }).join('');

    state.listEl.querySelectorAll('.file-browser-item').forEach((button) => {
      button.addEventListener('click', () => {
        const itemPath = normalizeBrowserPath(button.dataset.path || '');
        if (button.dataset.kind === 'directory') {
          loadFileBrowserDirectory(itemPath);
          return;
        }
        openFileBrowserFile(itemPath);
      });
    });

    syncFileBrowserSelection();
  }

  async function loadFileBrowserDirectory(targetPath, options = {}) {
    if (!fileBrowserState) return;
    const state = fileBrowserState;
    const normalizedPath = normalizeBrowserPath(targetPath);
    const previousPath = state.currentPath;
    const requestId = ++state.directoryRequestId;
    state.currentPath = normalizedPath;
    updateFileBrowserPathBar();
    state.listEl.innerHTML = '<div class="modal-loading">正在读取目录…</div>';
    setFileBrowserStatus('正在读取目录…');
    if (!options.preservePreview) {
      state.selectedFilePath = '';
      syncFileBrowserSelection();
      renderFileBrowserPreviewEmpty('文件预览', '选择一个文本文件查看内容');
      setFileBrowserPreviewMode(false);
    }

    try {
      const data = await fetchAuthJson(`/api/fs/list?sessionId=${encodeURIComponent(state.sessionId)}&path=${encodeURIComponent(normalizedPath)}`);
      if (!fileBrowserState || state !== fileBrowserState || requestId !== state.directoryRequestId) return;
      state.rootPath = data.rootPath || state.rootPath;
      state.currentPath = normalizeBrowserPath(data.currentPath || '');
      updateFileBrowserPathBar();
      renderFileBrowserDirectory(data.entries || []);
      const statusParts = [];
      if (data.truncated) statusParts.push(`目录较大，仅显示前 ${data.entryLimit} 项`);
      statusParts.push(`当前共 ${Array.isArray(data.entries) ? data.entries.length : 0} 项`);
      setFileBrowserStatus(statusParts.join(' · '));
    } catch (err) {
      if (!fileBrowserState || state !== fileBrowserState || requestId !== state.directoryRequestId) return;
      state.currentPath = previousPath;
      updateFileBrowserPathBar();
      state.listEl.innerHTML = `<div class="modal-empty">${escapeHtml(err.message || '目录读取失败')}</div>`;
      setFileBrowserStatus(err.message || '目录读取失败', 'error');
    }
  }

  function scrollFileBrowserPreviewToLine(lineNumber) {
    const line = Number(lineNumber || 0);
    const codeEl = fileBrowserState?.previewCodeEl;
    if (!codeEl || line <= 0) return;
    requestAnimationFrame(() => {
      const styles = window.getComputedStyle(codeEl);
      const lineHeight = parseFloat(styles.lineHeight) || (parseFloat(styles.fontSize) * 1.4) || 18;
      codeEl.scrollTop = Math.max(0, (line - 4) * lineHeight);
    });
  }

  async function openFileBrowserFile(targetPath, options = {}) {
    if (!fileBrowserState) return;
    const state = fileBrowserState;
    const normalizedPath = normalizeBrowserPath(targetPath);
    const targetLine = Math.max(0, Number(options.line || 0) || 0);
    const requestId = ++state.previewRequestId;
    state.selectedFilePath = normalizedPath;
    syncFileBrowserSelection();
    renderFileBrowserPreviewLoading(normalizedPath.split('/').pop() || '文件预览');
    setFileBrowserPreviewMode(true);

    try {
      const data = await fetchAuthJson(`/api/fs/read?sessionId=${encodeURIComponent(state.sessionId)}&path=${encodeURIComponent(normalizedPath)}`);
      if (!fileBrowserState || state !== fileBrowserState || requestId !== state.previewRequestId) return;
      state.selectedFilePath = normalizeBrowserPath(data.path || normalizedPath);
      syncFileBrowserSelection();
      state.previewTitleEl.textContent = data.name || '文件预览';
      const metaParts = [formatFileSize(data.size || 0)];
      if (data.updatedAt) metaParts.push(timeAgo(data.updatedAt));
      if (data.truncated) metaParts.push(`仅显示前 ${formatFileSize(data.previewBytes || 0)}`);
      if (targetLine) metaParts.push(`第 ${targetLine} 行`);
      state.previewMetaEl.textContent = metaParts.join(' · ');
      state.previewEmptyEl.hidden = true;
      state.previewCodeEl.hidden = false;
      state.previewCodeEl.textContent = data.content || '';
      scrollFileBrowserPreviewToLine(targetLine);
      setFileBrowserStatus(`已打开 ${data.name || '文件'}${targetLine ? `:${targetLine}` : ''}`);
    } catch (err) {
      if (!fileBrowserState || state !== fileBrowserState || requestId !== state.previewRequestId) return;
      state.selectedFilePath = '';
      syncFileBrowserSelection();
      renderFileBrowserPreviewEmpty('无法预览', err.message || '当前文件无法打开');
      setFileBrowserStatus(err.message || '当前文件无法打开', 'error');
    }
  }

  function showFileBrowser() {
    if (!currentSessionId) {
      showToast('请先打开一个会话');
      return;
    }
    if (!currentCwd) {
      showToast('当前会话没有可浏览的工作目录');
      return;
    }
    if (fileBrowserState && fileBrowserState.sessionId === currentSessionId) return;

    closeFileBrowser();

    const overlay = document.createElement('div');
    overlay.className = 'modal-overlay';
    overlay.id = 'file-browser-overlay';
    overlay.innerHTML = `
      <div class="modal-panel modal-panel-wide file-browser-panel">
        <div class="modal-header">
          <span class="modal-title">文件浏览器</span>
          <button class="modal-close-btn" type="button" data-browser-close>✕</button>
        </div>
        <div class="modal-body file-browser-body">
          <div class="file-browser-toolbar">
            <button class="file-browser-toolbar-btn" type="button" data-browser-up>上一级</button>
            <button class="file-browser-toolbar-btn" type="button" data-browser-refresh>刷新</button>
            <div class="file-browser-path" data-browser-path></div>
          </div>
          <div class="file-browser-status" data-browser-status>正在准备目录…</div>
          <div class="file-browser-layout">
            <section class="file-browser-pane file-browser-list-pane">
              <div class="file-browser-pane-title">目录与文件</div>
              <div class="file-browser-list" data-browser-list></div>
            </section>
            <section class="file-browser-pane file-browser-preview-pane">
              <div class="file-browser-preview-header">
                <button class="file-browser-mobile-back" type="button" data-browser-back>返回目录</button>
                <div class="file-browser-preview-copy">
                  <div class="file-browser-preview-title" data-browser-preview-title>文件预览</div>
                  <div class="file-browser-preview-meta" data-browser-preview-meta>选择一个文本文件查看内容</div>
                </div>
              </div>
              <div class="file-browser-preview-empty" data-browser-preview-empty>选择一个文本文件查看内容</div>
              <pre class="file-browser-preview-content" data-browser-preview-content hidden></pre>
            </section>
          </div>
        </div>
      </div>
    `;

    document.body.appendChild(overlay);

    const state = {
      sessionId: currentSessionId,
      rootPath: currentCwd,
      currentPath: '',
      selectedFilePath: '',
      directoryRequestId: 0,
      previewRequestId: 0,
      overlay,
      panel: overlay.querySelector('.file-browser-panel'),
      pathEl: overlay.querySelector('[data-browser-path]'),
      statusEl: overlay.querySelector('[data-browser-status]'),
      listEl: overlay.querySelector('[data-browser-list]'),
      previewTitleEl: overlay.querySelector('[data-browser-preview-title]'),
      previewMetaEl: overlay.querySelector('[data-browser-preview-meta]'),
      previewEmptyEl: overlay.querySelector('[data-browser-preview-empty]'),
      previewCodeEl: overlay.querySelector('[data-browser-preview-content]'),
      upBtn: overlay.querySelector('[data-browser-up]'),
      refreshBtn: overlay.querySelector('[data-browser-refresh]'),
      mobileBackBtn: overlay.querySelector('[data-browser-back]'),
      escapeHandler: null,
    };
    fileBrowserState = state;

    state.escapeHandler = (e) => {
      if (e.key === 'Escape') closeFileBrowser();
    };
    document.addEventListener('keydown', state.escapeHandler);

    overlay.querySelector('[data-browser-close]').addEventListener('click', closeFileBrowser);
    overlay.addEventListener('click', (e) => {
      if (e.target === overlay) closeFileBrowser();
    });
    state.upBtn.addEventListener('click', () => {
      loadFileBrowserDirectory(getBrowserParentPath(state.currentPath));
    });
    state.refreshBtn.addEventListener('click', () => {
      loadFileBrowserDirectory(state.currentPath, { preservePreview: !!state.selectedFilePath });
    });
    state.mobileBackBtn.addEventListener('click', () => {
      setFileBrowserPreviewMode(false);
    });

    updateFileBrowserPathBar();
    renderFileBrowserPreviewEmpty('文件预览', '选择一个文本文件查看内容');
    loadFileBrowserDirectory('');
  }

  function syncAttachmentActions() {
    const uploading = uploadingAttachments.length > 0;
    if (attachBtn) attachBtn.disabled = uploading;
  }

  function replaceFileExtension(filename, ext) {
    const base = String(filename || 'image').replace(/\.[^/.]+$/, '');
    return `${base}${ext}`;
  }

  function loadImageFromFile(file) {
    return new Promise((resolve, reject) => {
      const url = URL.createObjectURL(file);
      const img = new Image();
      img.onload = () => {
        URL.revokeObjectURL(url);
        resolve(img);
      };
      img.onerror = () => {
        URL.revokeObjectURL(url);
        reject(new Error('读取图片失败'));
      };
      img.src = url;
    });
  }

  async function compressImageFile(file) {
    if (!file || !/^image\/(png|jpeg|webp)$/i.test(file.type || '')) return file;
    const img = await loadImageFromFile(file);
    const maxDimension = 2000;
    const maxOriginalBytes = 2 * 1024 * 1024;
    const largestSide = Math.max(img.naturalWidth || img.width, img.naturalHeight || img.height);
    if (file.size <= maxOriginalBytes && largestSide <= maxDimension) {
      return file;
    }

    const scale = Math.min(1, maxDimension / largestSide);
    const width = Math.max(1, Math.round((img.naturalWidth || img.width) * scale));
    const height = Math.max(1, Math.round((img.naturalHeight || img.height) * scale));
    const canvas = document.createElement('canvas');
    canvas.width = width;
    canvas.height = height;
    const ctx = canvas.getContext('2d', { alpha: true });
    if (!ctx) return file;
    ctx.drawImage(img, 0, 0, width, height);

    const targetType = 'image/webp';
    const qualities = [0.9, 0.84, 0.78, 0.72];
    let bestBlob = null;
    for (const quality of qualities) {
      const blob = await new Promise((resolve) => canvas.toBlob(resolve, targetType, quality));
      if (!blob) continue;
      if (!bestBlob || blob.size < bestBlob.size) bestBlob = blob;
      if (blob.size <= Math.max(maxOriginalBytes, file.size * 0.72)) break;
    }
    if (!bestBlob || bestBlob.size >= file.size) return file;
    return new File([bestBlob], replaceFileExtension(file.name || 'image', '.webp'), {
      type: bestBlob.type,
      lastModified: Date.now(),
    });
  }

  async function deleteUploadedAttachment(id) {
    if (!id) return;
    try {
      await ensureAuthenticatedWs();
      await fetch(`/api/attachments/${encodeURIComponent(id)}`, {
        method: 'DELETE',
        headers: {
          Authorization: `Bearer ${authToken}`,
        },
      });
    } catch {}
    clearAttachmentPreviewCache(id);
  }

  function ensureAuthenticatedWs() {
    return new Promise((resolve, reject) => {
      if (ws && ws.readyState === 1 && authToken) {
        resolve(authToken);
        return;
      }
      const savedPassword = localStorage.getItem('cc-web-pw');
      if (!savedPassword) {
        reject(new Error('登录状态已失效，请刷新页面后重新登录再上传图片。'));
        return;
      }
      const timeout = setTimeout(() => {
        reject(new Error('登录状态恢复超时，请刷新页面后重试。'));
      }, 8000);

      const cleanup = () => {
        clearTimeout(timeout);
        document.removeEventListener('cc-web-auth-restored', onRestored);
        document.removeEventListener('cc-web-auth-failed', onFailed);
      };
      const onRestored = () => {
        cleanup();
        resolve(authToken);
      };
      const onFailed = () => {
        cleanup();
        reject(new Error('登录状态已失效，请刷新页面后重新登录再上传图片。'));
      };
      document.addEventListener('cc-web-auth-restored', onRestored);
      document.addEventListener('cc-web-auth-failed', onFailed);

      if (!ws || ws.readyState > 1) {
        connect();
      } else if (ws.readyState === 1) {
        send({ type: 'auth', password: savedPassword });
      }
    });
  }

  function clearAttachmentPreviewCache(id) {
    const entry = attachmentPreviewCache.get(id);
    if (entry?.url && entry.objectUrl) URL.revokeObjectURL(entry.url);
    attachmentPreviewCache.delete(id);
  }

  async function getAttachmentPreviewUrl(attachment) {
    const id = String(attachment?.id || '').trim();
    if (!id) throw new Error('图片附件缺少 ID');
    if (attachment.storageState === 'expired') {
      throw new Error('图片已过期');
    }
    if (attachment.previewUrl) return attachment.previewUrl;

    const cached = attachmentPreviewCache.get(id);
    if (cached?.url) return cached.url;
    if (cached?.promise) return cached.promise;

    const promise = (async () => {
      const fetchAttachment = async () => {
        await ensureAuthenticatedWs();
        if (!authToken) {
          throw new Error('登录状态已失效，请刷新页面后重新登录再预览图片。');
        }
        return fetch(`/api/attachments/${encodeURIComponent(id)}`, {
          cache: 'no-store',
          headers: {
            Authorization: `Bearer ${authToken}`,
          },
        });
      };

      let response = await fetchAttachment();
      if (response.status === 401 && localStorage.getItem('cc-web-pw')) {
        authToken = null;
        localStorage.removeItem('cc-web-token');
        response = await fetchAttachment();
      }
      if (response.status === 401) {
        throw new Error('登录状态已失效，请刷新页面后重新登录再预览图片。');
      }
      if (response.status === 404) {
        throw new Error('图片不存在或已过期');
      }
      if (!response.ok) {
        throw new Error('图片预览失败');
      }
      const blob = await response.blob();
      if (!blob || blob.size === 0) {
        throw new Error('图片预览失败');
      }
      const url = URL.createObjectURL(blob);
      attachmentPreviewCache.set(id, { url, objectUrl: true });
      return url;
    })().catch((err) => {
      attachmentPreviewCache.delete(id);
      throw err;
    });

    attachmentPreviewCache.set(id, { promise });
    return promise;
  }

  function closeAttachmentPreviewModal() {
    if (!attachmentPreviewModal) return;
    const { overlay, escapeHandler } = attachmentPreviewModal;
    if (escapeHandler) document.removeEventListener('keydown', escapeHandler);
    if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay);
    attachmentPreviewModal = null;
  }

  async function openAttachmentPreviewModal(attachment) {
    if (!attachment || attachment.storageState === 'expired') {
      showToast('图片已过期，无法预览');
      return;
    }

    closeAttachmentPreviewModal();

    const overlay = document.createElement('div');
    overlay.className = 'modal-overlay attachment-preview-overlay';
    overlay.innerHTML = `
      <div class="modal-panel modal-panel-wide attachment-preview-panel">
        <div class="modal-header">
          <span class="modal-title">图片预览</span>
          <button class="modal-close-btn" type="button" aria-label="关闭">✕</button>
        </div>
        <div class="attachment-preview-body">
          <div class="attachment-preview-stage is-loading">
            <div class="attachment-preview-placeholder">正在加载图片…</div>
            <img class="attachment-preview-image" alt="${escapeHtml(attachment.filename || 'image')}" hidden>
          </div>
          <div class="attachment-preview-meta">
            <div class="attachment-preview-name">${escapeHtml(attachment.filename || 'image')}</div>
            <div class="attachment-preview-desc">${escapeHtml(formatFileSize(attachment.size || 0))} · 点击空白处关闭</div>
          </div>
        </div>
      </div>
    `;

    document.body.appendChild(overlay);
    const stageEl = overlay.querySelector('.attachment-preview-stage');
    const imgEl = overlay.querySelector('.attachment-preview-image');
    const placeholderEl = overlay.querySelector('.attachment-preview-placeholder');
    const closeBtn = overlay.querySelector('.modal-close-btn');

    const finishClose = () => closeAttachmentPreviewModal();
    attachmentPreviewModal = {
      overlay,
      escapeHandler: null,
    };

    attachmentPreviewModal.escapeHandler = (e) => {
      if (e.key === 'Escape') finishClose();
    };
    document.addEventListener('keydown', attachmentPreviewModal.escapeHandler);

    closeBtn.addEventListener('click', finishClose);
    overlay.addEventListener('click', (e) => {
      if (e.target === overlay) finishClose();
    });

    try {
      const url = await getAttachmentPreviewUrl(attachment);
      if (!attachmentPreviewModal || attachmentPreviewModal.overlay !== overlay) return;
      imgEl.onload = () => {
        if (!attachmentPreviewModal || attachmentPreviewModal.overlay !== overlay) return;
        imgEl.hidden = false;
        placeholderEl.hidden = true;
        stageEl.classList.remove('is-loading');
        stageEl.classList.add('is-ready');
      };
      imgEl.onerror = () => {
        if (!attachmentPreviewModal || attachmentPreviewModal.overlay !== overlay) return;
        placeholderEl.textContent = '图片加载失败';
        stageEl.classList.remove('is-loading');
        stageEl.classList.add('is-error');
      };
      imgEl.src = url;
    } catch (err) {
      if (!attachmentPreviewModal || attachmentPreviewModal.overlay !== overlay) return;
      placeholderEl.textContent = err.message || '图片预览失败';
      stageEl.classList.remove('is-loading');
      stageEl.classList.add('is-error');
    }
  }

  function hydrateAttachmentPreviews(root, attachments = []) {
    if (!root) return;
    const attachmentMap = new Map((Array.isArray(attachments) ? attachments : []).map((attachment) => [attachment.id, attachment]));
    root.querySelectorAll('[data-attachment-id]').forEach((node) => {
      const attachment = attachmentMap.get(node.dataset.attachmentId);
      if (!attachment) return;
      const imgEl = node.querySelector('.msg-attachment-thumb-image');
      const placeholderEl = node.querySelector('.msg-attachment-thumb-placeholder');
      const isExpired = attachment.storageState === 'expired';

      if (!isExpired) {
        getAttachmentPreviewUrl(attachment)
          .then((url) => {
            if (!node.isConnected) return;
            imgEl.onload = () => {
              if (!node.isConnected) return;
              imgEl.hidden = false;
              placeholderEl.hidden = true;
              node.classList.remove('is-error');
              node.classList.add('is-loaded');
            };
            imgEl.onerror = () => {
              if (!node.isConnected) return;
              placeholderEl.textContent = '图片加载失败';
              node.classList.remove('is-loaded');
              node.classList.add('is-error');
            };
            imgEl.src = url;
          })
          .catch((err) => {
            if (!node.isConnected) return;
            placeholderEl.textContent = err.message || '图片加载失败';
            node.classList.add('is-error');
          });
      }

      node.addEventListener('click', () => openAttachmentPreviewModal(attachment));
    });
  }

  function renderAttachmentPreviews(attachments, options = {}) {
    if (!Array.isArray(attachments) || attachments.length === 0) return '';
    const items = attachments.map((attachment) => {
      const state = attachment.storageState || 'available';
      const name = escapeHtml(attachment.filename || 'image');
      const size = formatFileSize(attachment.size || 0);
      const isExpired = state === 'expired';
      return `
        <button class="msg-attachment-card${isExpired ? ' is-expired' : ''}" type="button" data-attachment-id="${escapeHtml(attachment.id || '')}" data-attachment-state="${escapeHtml(state)}" data-attachment-name="${name}" ${isExpired ? 'disabled' : ''} title="${isExpired ? '图片已过期' : '点击放大预览'}">
          <span class="msg-attachment-thumb">
            <span class="msg-attachment-thumb-placeholder">${isExpired ? '已过期' : '加载中'}</span>
            <img class="msg-attachment-thumb-image" alt="${name}" hidden>
          </span>
          <span class="msg-attachment-meta">
            <span class="msg-attachment-name">图片: ${name}</span>
            <span class="msg-attachment-note">${isExpired ? '已过期' : `${size} · 点击放大预览`}</span>
          </span>
        </button>
      `;
    }).join('');
    return `<div class="msg-attachments${options.compact ? ' compact' : ''}">${items}</div>`;
  }

  function renderPendingAttachments() {
    if (!attachmentTray) return;
    if (!pendingAttachments.length && !uploadingAttachments.length) {
      attachmentTray.hidden = true;
      attachmentTray.innerHTML = '';
      syncAttachmentActions();
      return;
    }
    attachmentTray.hidden = false;
    const uploadingHtml = uploadingAttachments.map((attachment) => `
      <div class="attachment-chip uploading">
        <div class="attachment-chip-meta">
          <span class="attachment-chip-name">${escapeHtml(attachment.filename || 'image')}</span>
          <span class="attachment-chip-note">上传中 · ${formatFileSize(attachment.size)}</span>
        </div>
      </div>
    `).join('');
    const readyHtml = pendingAttachments.map((attachment, index) => `
      <div class="attachment-chip" data-index="${index}">
        <div class="attachment-chip-meta">
          <span class="attachment-chip-name">${escapeHtml(attachment.filename || 'image')}</span>
          <span class="attachment-chip-note">${formatFileSize(attachment.size)} · 将随下一条消息发送</span>
        </div>
        <button class="attachment-chip-remove" type="button" data-index="${index}" title="移除">✕</button>
      </div>
    `).join('');
    const noteHtml = [
      uploadingAttachments.length > 0
        ? '<div class="attachment-tray-note">图片上传中，此时发送不会包含尚未完成的图片。</div>'
        : '',
    ].join('');
    attachmentTray.innerHTML = `${uploadingHtml}${readyHtml}${noteHtml}`;
    attachmentTray.querySelectorAll('.attachment-chip-remove').forEach((btn) => {
      btn.addEventListener('click', (e) => {
        e.stopPropagation();
        const index = Number(btn.dataset.index);
        const [removed] = pendingAttachments.splice(index, 1);
        renderPendingAttachments();
        deleteUploadedAttachment(removed?.id);
      });
    });
    syncAttachmentActions();
  }

  async function uploadImageFile(file) {
    await ensureAuthenticatedWs();
    const headers = {
      'Authorization': `Bearer ${authToken}`,
      'Content-Type': file.type || 'application/octet-stream',
      'X-Filename': encodeURIComponent(file.name || 'image'),
    };
    const response = await fetch('/api/attachments', {
      method: 'POST',
      headers,
      body: file,
    });
    const rawText = await response.text();
    let data = null;
    try {
      data = rawText ? JSON.parse(rawText) : null;
    } catch {
      data = null;
    }
    if (response.status === 401) {
      throw new Error('登录状态已失效，请刷新页面后重新登录再上传图片。');
    }
    if (response.status === 413) {
      throw new Error('图片大小超过当前上传限制，请压缩到 10MB 以内后重试。');
    }
    if (!response.ok || !data?.ok) {
      throw new Error(data?.message || `上传失败 (${response.status})`);
    }
    return data.attachment;
  }

  async function handleSelectedImageFiles(fileList) {
    const files = Array.from(fileList || []).filter((file) => file && /^image\//.test(file.type || ''));
    if (!files.length) return;
    if (pendingAttachments.length + files.length > 4) {
      appendError('单条消息最多附带 4 张图片。');
      return;
    }
    const batch = files.map((file, index) => ({
      id: `${Date.now()}-${index}-${Math.random().toString(36).slice(2, 8)}`,
      filename: file.name || 'image',
      size: file.size || 0,
    }));
    uploadingAttachments.push(...batch);
    renderPendingAttachments();
    try {
      const results = await Promise.allSettled(files.map(async (file) => {
        const optimized = await compressImageFile(file);
        const uploaded = await uploadImageFile(optimized);
        uploaded.previewUrl = URL.createObjectURL(file);
        return uploaded;
      }));
      const errors = [];
      for (const result of results) {
        if (result.status === 'fulfilled') {
          pendingAttachments.push(result.value);
        } else {
          errors.push(result.reason?.message || '图片上传失败');
        }
      }
      if (errors.length > 0) {
        appendError(errors[0]);
      }
    } catch (err) {
      appendError(err.message || '图片上传失败');
    } finally {
      uploadingAttachments = uploadingAttachments.filter((item) => !batch.some((entry) => entry.id === item.id));
      renderPendingAttachments();
      if (imageUploadInput) imageUploadInput.value = '';
    }
  }

  function getVisibleSessions() {
    return sessions.filter((s) => isPrimaryUiAgent(s.agent));
  }

  function normalizeSessionSearchQuery(query) {
    return String(query || '').trim().toLowerCase();
  }

  function syncSessionSearchUi() {
    if (!sessionSearchInput) return;
    if (sessionSearchInput.value !== sessionSearchQuery) {
      sessionSearchInput.value = sessionSearchQuery;
    }
    const hasQuery = !!normalizeSessionSearchQuery(sessionSearchQuery);
    sessionSearchInput.classList.toggle('has-value', hasQuery);
    if (sessionSearchClear) {
      sessionSearchClear.hidden = !hasQuery;
      sessionSearchClear.disabled = !hasQuery;
    }
  }

  function getSessionSearchText(session) {
    const cwd = getSessionEffectiveCwd(session);
    return [
      session?.title,
      getSessionProjectName(session),
      cwd,
      session?.id,
      shortSessionId(session?.id),
    ].filter(Boolean).join('\n').toLowerCase();
  }

  function sessionMatchesSearch(session, normalizedQuery) {
    if (!normalizedQuery) return true;
    return getSessionSearchText(session).includes(normalizedQuery);
  }

  function getProjectCollapseKey(group) {
    const rawKey = group?.cwd || group?.name || '';
    return `${normalizeAgent(currentAgent)}:${rawKey}`;
  }

  function persistCollapsedProjectKeys() {
    try {
      localStorage.setItem(PROJECT_COLLAPSE_STORAGE_KEY, JSON.stringify([...collapsedProjectKeys]));
    } catch {}
  }

  function setProjectCollapsed(groupKey, collapsed) {
    if (!groupKey) return;
    if (collapsed) {
      collapsedProjectKeys.add(groupKey);
    } else {
      collapsedProjectKeys.delete(groupKey);
    }
    persistCollapsedProjectKeys();
    renderSessionList();
  }

  function getSessionCwdFromCache(sessionId) {
    if (!sessionId) return '';
    const cachedCwd = sessionCache.get(sessionId)?.snapshot?.cwd;
    if (cachedCwd) return cachedCwd;
    if (sessionId === currentSessionId && currentCwd) return currentCwd;
    return '';
  }

  function getSessionEffectiveCwd(session) {
    return session?.cwd || getSessionCwdFromCache(session?.id) || '';
  }

  function getSessionProjectName(session) {
    if (session?.projectName) return session.projectName;
    const cwd = String(getSessionEffectiveCwd(session)).replace(/\\/g, '/').replace(/\/+$/, '');
    return cwd ? (getPathLeaf(cwd) || cwd) : '';
  }

  function groupSessionsByProject(sessionItems) {
    const groups = [];
    const groupMap = new Map();
    const ungroupedSessions = [];
    for (const session of sessionItems) {
      const projectName = getSessionProjectName(session);
      if (!projectName) {
        ungroupedSessions.push(session);
        continue;
      }
      if (!groupMap.has(projectName)) {
        const cwd = getSessionEffectiveCwd(session);
        const group = {
          name: projectName,
          cwd,
          sessions: [],
          latestUpdated: session.updated || '',
        };
        groupMap.set(projectName, group);
        groups.push(group);
      }
      const group = groupMap.get(projectName);
      group.sessions.push(session);
      if (new Date(session.updated || 0) > new Date(group.latestUpdated || 0)) {
        group.latestUpdated = session.updated || group.latestUpdated;
        group.cwd = getSessionEffectiveCwd(session) || group.cwd;
      }
    }
    for (const group of groups) {
      group.sessions.sort(compareSessionUpdatedDesc);
    }
    ungroupedSessions.sort(compareSessionUpdatedDesc);
    return {
      groups: groups.sort((a, b) => new Date(b.latestUpdated || 0) - new Date(a.latestUpdated || 0)),
      ungroupedSessions,
    };
  }

  function splitPinnedSessions(sessionItems) {
    const pinnedSessions = [];
    const regularSessions = [];
    for (const session of sessionItems) {
      if (session.pinnedAt) {
        pinnedSessions.push(session);
      } else {
        regularSessions.push(session);
      }
    }
    pinnedSessions.sort(compareSessionPinnedDesc);
    regularSessions.sort(compareSessionUpdatedDesc);
    return { pinnedSessions, regularSessions };
  }

  function isOlderThanOldSessionWindow(session, nowMs = Date.now()) {
    const updatedMs = new Date(session?.updated || 0).getTime();
    return Number.isFinite(updatedMs) && updatedMs > 0 && nowMs - updatedMs > OLD_SESSION_COLLAPSE_MS;
  }

  function getProjectOldSessionCollapseKey(group) {
    return `project:${getProjectCollapseKey(group)}`;
  }

  function getUngroupedOldSessionCollapseKey() {
    return `${normalizeAgent(currentAgent)}:ungrouped`;
  }

  function expandOldSessionGroup(collapseKey) {
    if (!collapseKey) return;
    expandedOldSessionGroups.add(collapseKey);
  }

  function shouldAlwaysShowOldSession(session) {
    return session.id === currentSessionId || session.isRunning || session.hasUnread || session.waitingOnChildren;
  }

  function splitCollapsedSessions(sessionItems, collapseKey) {
    const isExpanded = collapseKey && expandedOldSessionGroups.has(collapseKey);
    const nowMs = Date.now();
    const shouldCompactByCount = sessionItems.length > SESSION_GROUP_COMPACT_VISIBLE_LIMIT;
    const visibleSessions = [];
    const hiddenSessions = [];

    sessionItems.forEach((session) => {
      const isOldSession = isOlderThanOldSessionWindow(session, nowMs);
      const shouldHideByAge = isOldSession && visibleSessions.length >= OLD_SESSION_GROUP_INITIAL_VISIBLE;
      const shouldHideByCount = shouldCompactByCount && visibleSessions.length >= SESSION_GROUP_COMPACT_VISIBLE_LIMIT;
      const shouldHideSession = (
        !isExpanded
        && !shouldAlwaysShowOldSession(session)
        && (shouldHideByAge || shouldHideByCount)
      );
      if (shouldHideSession) {
        hiddenSessions.push(session);
      } else {
        visibleSessions.push(session);
      }
    });

    return { visibleSessions, hiddenSessions };
  }

  function createOldSessionLoadMoreButton(hiddenCount, collapseKey, projectName = '') {
    const button = document.createElement('button');
    button.type = 'button';
    button.className = 'session-list-load-more';
    button.setAttribute('aria-label', `加载更多${projectName ? ` ${projectName}` : ''}会话，还有 ${hiddenCount} 条`);
    button.innerHTML = `
      <span class="session-list-load-more-title">加载更多</span>
      <span class="session-list-load-more-meta">还有 ${hiddenCount} 条</span>
    `;
    button.addEventListener('click', () => {
      expandOldSessionGroup(collapseKey);
      renderSessionList();
    });
    return button;
  }

  function applySessionPinnedState(sessionId, pinnedAt) {
    sessions = sessions.map((session) => (
      session.id === sessionId ? { ...session, pinnedAt: pinnedAt || null } : session
    ));
    updateCachedSession(sessionId, (snapshot) => {
      snapshot.pinnedAt = pinnedAt || null;
    });
    renderSessionList();
  }

  function toggleSessionPinned(session) {
    if (!session?.id) return;
    const nextPinned = !session.pinnedAt;
    const pinnedAt = nextPinned ? new Date().toISOString() : null;
    applySessionPinnedState(session.id, pinnedAt);
    send({ type: 'set_session_pinned', sessionId: session.id, pinned: nextPinned });
  }

  function setSessionActionMenuOpen(item, open) {
    if (!item) return;
    item.classList.toggle('menu-open', open);
    item.querySelector('.session-item-btn.more')?.setAttribute('aria-expanded', open ? 'true' : 'false');
  }

  function closeSessionActionMenus(exceptItem = null) {
    document.querySelectorAll('.session-item.menu-open').forEach((item) => {
      if (item !== exceptItem) setSessionActionMenuOpen(item, false);
    });
  }

  function createSessionListItem(session) {
    const item = document.createElement('div');
    const isPinned = !!session.pinnedAt;
    const isLlmCreated = String(session.createdFromKind || '').toLowerCase() === 'mcp';
    const waitingOnChildren = !!session.waitingOnChildren;
    const readyReplyCount = Number(session.readyReplyCount || 0);
    const waitingLabel = readyReplyCount > 0 ? `子对话已返回 ${readyReplyCount}` : `等待子对话 ${Number(session.pendingReplyCount || 0) || ''}`.trim();
    item.className = `session-item${session.id === currentSessionId ? ' active' : ''}${isPinned ? ' pinned' : ''}${isLlmCreated ? ' llm-created' : ''}${waitingOnChildren ? ' waiting-children' : ''}`;
    item.dataset.id = session.id;
    const sessionCwd = getSessionEffectiveCwd(session);
    if (sessionCwd) item.title = sessionCwd;
    item.innerHTML = `
      <div class="session-item-main">
        <span class="session-item-title">${escapeHtml(session.title || 'Untitled')}</span>
        ${isPinned ? '<span class="session-item-pin-badge" title="已置顶">顶</span>' : ''}
        ${session.isRunning ? '<span class="session-item-status">运行中</span>' : ''}
        ${!session.isRunning && waitingOnChildren ? `<span class="session-item-status waiting">${escapeHtml(waitingLabel)}</span>` : ''}
      </div>
      ${session.hasUnread ? '<span class="session-unread-dot"></span>' : ''}
      <span class="session-item-time">${timeAgo(session.updated)}</span>
      <div class="session-item-actions">
        <button class="session-item-btn pin${isPinned ? ' active' : ''}" title="${isPinned ? '取消置顶' : '置顶'}" aria-label="${isPinned ? '取消置顶' : '置顶'}">${isPinned ? '✓' : '⇧'}</button>
        <div class="session-item-more">
          <button class="session-item-btn more" type="button" title="更多操作" aria-label="更多操作" aria-haspopup="menu" aria-expanded="false">⋯</button>
          <div class="session-item-menu" role="menu" aria-label="会话操作">
            <button class="session-item-menu-btn copy-id" type="button" role="menuitem">复制 ID</button>
            <button class="session-item-menu-btn edit" type="button" role="menuitem">重命名</button>
            <button class="session-item-menu-btn delete" type="button" role="menuitem">删除</button>
          </div>
        </div>
      </div>
    `;

    item.addEventListener('click', (e) => {
      const target = e.target instanceof Element
        ? e.target.closest('.session-item-btn, .session-item-menu-btn')
        : null;
      if (target?.classList.contains('more')) {
        e.stopPropagation();
        const nextOpen = !item.classList.contains('menu-open');
        closeSessionActionMenus(item);
        setSessionActionMenuOpen(item, nextOpen);
        return;
      }
      if (target?.classList.contains('copy-id')) {
        e.stopPropagation();
        closeSessionActionMenus();
        copyTextToClipboard(session.id, '会话 ID 已复制');
        return;
      }
      if (target?.classList.contains('pin')) {
        e.stopPropagation();
        closeSessionActionMenus();
        toggleSessionPinned(session);
        return;
      }
      if (target?.classList.contains('delete')) {
        e.stopPropagation();
        closeSessionActionMenus();
        const doDelete = () => {
          if (getLastSessionForAgent(currentAgent) === session.id) {
            localStorage.removeItem(getAgentSessionStorageKey(currentAgent));
          }
          pendingNotesByTarget.delete(session.id);
          queuedMessagesByTarget.delete(getSessionQueueKey(session.id));
          invalidateSessionCache(session.id);
          send({ type: 'delete_session', sessionId: session.id });
          if (session.id === currentSessionId) {
            resetChatView(currentAgent);
          }
        };
        if (skipDeleteConfirm) {
          doDelete();
        } else {
          showDeleteConfirm(session.agent, doDelete);
        }
        return;
      }
      if (target?.classList.contains('edit')) {
        e.stopPropagation();
        closeSessionActionMenus();
        startEditSessionTitle(item, session);
        return;
      }
      if (e.target instanceof Element && e.target.closest('.session-item-more')) {
        e.stopPropagation();
        return;
      }
      closeSessionActionMenus();
      if (isMobileInputMode()) closeSidebar();
      openSession(session.id);
    });

    return item;
  }

  function updateCwdBadge() {
    if (!chatCwd) return;
    if (currentCwd) {
      const parts = currentCwd.replace(/\/+$/, '').split('/');
      const short = parts.slice(-2).join('/') || currentCwd;
      chatCwd.textContent = '~/' + short;
      chatCwd.title = `${currentCwd}\n点击浏览目录和文件`;
      chatCwd.setAttribute('aria-label', `浏览工作目录 ${currentCwd}`);
    } else {
      chatCwd.textContent = '';
      chatCwd.title = '';
      chatCwd.removeAttribute('aria-label');
    }
    chatCwd.disabled = !currentCwd;
    chatCwd.hidden = !currentCwd;
  }

  function currentSessionWaitState() {
    const meta = currentSessionId ? getSessionMeta(currentSessionId) : null;
    const cached = currentSessionId ? sessionCache.get(currentSessionId)?.snapshot : null;
    const source = meta || cached || {};
    return {
      waitingOnChildren: !!source.waitingOnChildren,
      pendingReplyCount: Number(source.pendingReplyCount || 0),
      readyReplyCount: Number(source.readyReplyCount || 0),
    };
  }

  function setCurrentSessionRunningState(isRunning) {
    const running = !!isRunning;
    currentSessionRunning = running;
    if (chatRuntimeState) {
      const waitState = currentSessionWaitState();
      if (running) {
        chatRuntimeState.hidden = false;
        chatRuntimeState.classList.remove('waiting');
        chatRuntimeState.textContent = '运行中';
      } else if (waitState.waitingOnChildren) {
        chatRuntimeState.hidden = false;
        chatRuntimeState.classList.add('waiting');
        chatRuntimeState.textContent = waitState.readyReplyCount > 0
          ? `子对话已返回 ${waitState.readyReplyCount}`
          : `等待子对话 ${waitState.pendingReplyCount || ''}`.trim();
      } else {
        chatRuntimeState.hidden = true;
        chatRuntimeState.classList.remove('waiting');
        chatRuntimeState.textContent = '';
      }
    }
    updateCwdBadge();
    if (!running) scheduleQueuedMessageDrain();
  }

  function updateAgentScopedUI() {
    if (chatAgentPicker) chatAgentPicker.hidden = true;
    if (chatAgentMenu) chatAgentMenu.hidden = true;
    if (chatAgentBtn) {
      chatAgentBtn.textContent = AGENT_LABELS[currentAgent];
      chatAgentBtn.setAttribute('aria-expanded', 'false');
    }
    if (chatAgentMenu) {
      chatAgentMenu.querySelectorAll('.chat-agent-option').forEach((btn) => {
        const active = btn.dataset.agent === currentAgent;
        btn.classList.toggle('active', active);
        btn.setAttribute('aria-pressed', active ? 'true' : 'false');
      });
    }
    if (importSessionBtn) {
      importSessionBtn.textContent = isCodexLikeAgent(currentAgent)
        ? `导入本地 ${AGENT_LABELS[currentAgent]} 会话`
        : '导入本地 Claude 会话';
      importSessionBtn.disabled = false;
    }
    updateReloadMcpButtonUI();
  }

  function setCurrentAgent(agent) {
    currentAgent = normalizeUiAgent(agent);
    localStorage.setItem('cc-web-agent', currentAgent);
    currentMode = localStorage.getItem(getAgentModeStorageKey(currentAgent)) || 'yolo';
    modeSelect.value = currentMode;
    updateAgentScopedUI();
    updateGenerationControls();
  }

  function closeAgentMenu() {
    if (!chatAgentMenu) return;
    chatAgentMenu.hidden = true;
    if (chatAgentBtn) chatAgentBtn.setAttribute('aria-expanded', 'false');
  }

  function toggleAgentMenu() {
    if (!chatAgentMenu || !chatAgentBtn) return;
    if (chatAgentPicker?.hidden) return;
    const willOpen = chatAgentMenu.hidden;
    chatAgentMenu.hidden = !willOpen;
    chatAgentBtn.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
  }

  function resetChatView(agent) {
    setCurrentAgent(agent);
    closeUserOutlinePanel();
    closeCcwebPromptOutlinePanel();
    closeFileBrowser();
    currentSessionId = null;
    loadedHistorySessionId = null;
    currentSessionMessageCount = 0;
    clearSessionLoading();
    setCurrentSessionRunningState(false);
    currentCwd = null;
    currentModel = isCodexLikeAgent(currentAgent) ? '' : 'opus';
    isGenerating = false;
    generatingSessionId = null;
    pendingText = '';
    window.pendingContentBlocks = [];
    pendingAttachments = [];
    uploadingAttachments = [];
    activeToolCalls.clear();
    activeTodoCallTargets.clear();
    closedCollabAgentIds = new Set();
    updateGenerationControls();
    chatTitle.textContent = '新会话';
    updateSessionIdBadge();
    updateCwdBadge();
    updateReloadMcpButtonUI();
    messagesDiv.innerHTML = buildWelcomeMarkup(currentAgent);
    setStatsDisplay(null);
    renderPendingAttachments();
    renderPendingNotes({ scroll: false });
    highlightActiveSession();
  }

  function applySessionSnapshot(snapshot, options = {}) {
    if (!snapshot) return false;
    const snapshotAgent = normalizeAgent(snapshot.agent);
    if (!isPrimaryUiAgent(snapshotAgent)) {
      if (currentSessionId === snapshot.sessionId) resetChatView(PRIMARY_UI_AGENT);
      renderSessionList();
      return false;
    }
    if (fileBrowserState && (fileBrowserState.sessionId !== snapshot.sessionId || (snapshot.cwd && fileBrowserState.rootPath !== snapshot.cwd))) {
      closeFileBrowser();
    }
    const hasStreamingElement = !!document.getElementById('streaming-msg');
    const preserveStreaming = !!(options.preserveStreaming &&
      snapshot.sessionId === currentSessionId &&
      snapshot.isRunning &&
      (isGenerating || currentSessionRunning || hasStreamingElement));
    if (isGenerating && !preserveStreaming) {
      isGenerating = false;
      generatingSessionId = null;
      updateGenerationControls();
      pendingText = '';
      window.pendingContentBlocks = [];
      activeToolCalls.clear();
      activeTodoCallTargets.clear();
    }
    currentSessionId = snapshot.sessionId;
    loadedHistorySessionId = snapshot.sessionId;
    currentSessionMessageCount = Math.max(
      snapshot.historyTotal || 0,
      snapshot.historyBaseIndex + (snapshot.messages || []).length,
      (snapshot.messages || []).length,
    );
    setLastSessionForAgent(snapshot.agent, currentSessionId);
    chatTitle.textContent = snapshot.title || '新会话';
    updateSessionIdBadge();
    setCurrentAgent(snapshotAgent);
    migratePendingNotesToSession(snapshot.sessionId, snapshotAgent);
    migrateQueuedMessagesToSession(snapshot.sessionId, snapshotAgent);
    setCurrentSessionRunningState(snapshot.isRunning);
    setStatsDisplay(snapshot);
    closeUserOutlinePanel();
    closeCcwebPromptOutlinePanel();
    currentCwd = snapshot.cwd || null;
    updateCwdBadge();
    if (snapshot.mode && MODE_LABELS[snapshot.mode]) {
      currentMode = snapshot.mode;
      modeSelect.value = currentMode;
      localStorage.setItem(getAgentModeStorageKey(currentAgent), currentMode);
    }
    currentModel = snapshot.model || '';
    if (!preserveStreaming) {
      renderMessages(snapshot.messages || [], {
        immediate: !!options.immediate,
        baseIndex: snapshot.historyBaseIndex || 0,
      });
      if (snapshot.isRunning && snapshot.sessionId === currentSessionId) {
        startGenerating(snapshot.sessionId);
      }
    } else {
      generatingSessionId = snapshot.sessionId;
    }
    highlightActiveSession();
    renderSessionList();
    if (!options.skipCloseSidebar) closeSidebar();
    if (snapshot.hasUnread && !options.suppressUnreadToast) {
      showToast('后台任务已完成', snapshot.sessionId);
    }
    return true;
  }

  function syncViewForAgent(agent, options = {}) {
    const targetAgent = normalizeUiAgent(agent);
    const { preserveCurrent = true, loadLast = true } = options;
    setCurrentAgent(targetAgent);
    closeUserOutlinePanel();
    closeCcwebPromptOutlinePanel();
    renderSessionList();

    const currentMeta = currentSessionId ? getSessionMeta(currentSessionId) : null;
    if (preserveCurrent && currentMeta && normalizeAgent(currentMeta.agent) === targetAgent) {
      highlightActiveSession();
      return;
    }

    if (currentSessionId && (!currentMeta || normalizeAgent(currentMeta.agent) !== targetAgent)) {
      send({ type: 'detach_view' });
    }

    resetChatView(targetAgent);

    if (!loadLast) return;
    const lastSessionId = getLastSessionForAgent(targetAgent);
    const lastMeta = lastSessionId ? getSessionMeta(lastSessionId) : null;
    if (lastMeta && normalizeAgent(lastMeta.agent) === targetAgent) {
      openSession(lastSessionId);
    }
  }

  function getSessionLoadLabel(sessionId) {
    const meta = sessionId ? getSessionMeta(sessionId) : null;
    const title = meta?.title ? `“${meta.title}”` : '所选会话';
    return `正在载入 ${title} 的完整消息记录…`;
  }

  function clearSessionLoadOverlayTimer() {
    if (!sessionLoadOverlayTimer) return;
    clearTimeout(sessionLoadOverlayTimer);
    sessionLoadOverlayTimer = null;
  }

  function clearSessionLoadRequestTimer() {
    if (!sessionLoadRequestTimer) return;
    clearTimeout(sessionLoadRequestTimer);
    sessionLoadRequestTimer = null;
  }

  function clearSessionResumeFallbackTimer() {
    if (!sessionResumeFallbackTimer) return;
    clearTimeout(sessionResumeFallbackTimer);
    sessionResumeFallbackTimer = null;
  }

  function clearPendingSessionSwitchRequest(sessionId, requestId) {
    if (!pendingSessionSwitchRequest) return;
    if (sessionId && pendingSessionSwitchRequest.sessionId !== sessionId) return;
    if (requestId && pendingSessionSwitchRequest.requestId !== requestId) return;
    pendingSessionSwitchRequest = null;
  }

  function clearPendingSessionResumeRequest(sessionId, requestId) {
    if (!pendingSessionResumeRequest) return;
    if (sessionId && pendingSessionResumeRequest.sessionId !== sessionId) return;
    if (requestId && pendingSessionResumeRequest.requestId !== requestId) return;
    pendingSessionResumeRequest = null;
    clearSessionResumeFallbackTimer();
  }

  function notifySessionLoadTimeout(sessionId) {
    const meta = sessionId ? getSessionMeta(sessionId) : null;
    const title = meta?.title ? `“${meta.title}”` : '所选会话';
    appendError(`${title} 加载超时，已取消本次切换。若服务刚重启或网络恢复后，可重新点击该会话。`, {
      transient: true,
      autoDismissMs: 9000,
      preserveScroll: false,
    });
  }

  function scheduleSessionLoadRequestTimeout(sessionId, requestId) {
    clearSessionLoadRequestTimer();
    if (!sessionId || !requestId) return;
    sessionLoadRequestTimer = setTimeout(() => {
      sessionLoadRequestTimer = null;
      if (!activeSessionLoad ||
        activeSessionLoad.sessionId !== sessionId ||
        activeSessionLoad.requestId !== requestId) {
        return;
      }
      clearPendingSessionSwitchRequest(sessionId, requestId);
      clearSessionLoading(sessionId);
      notifySessionLoadTimeout(sessionId);
    }, SESSION_LOAD_REQUEST_TIMEOUT_MS);
  }

  function releaseSessionLoadingOverlay({ keepActiveLoad = true, allowRetry = false } = {}) {
    clearSessionLoadOverlayTimer();
    document.body.classList.remove('session-loading-active');
    sessionLoadingOverlay.hidden = true;
    sessionLoadingOverlay.setAttribute('aria-hidden', 'true');
    msgInput.disabled = false;
    modeSelect.disabled = false;
    sendBtn.disabled = false;
    abortBtn.disabled = false;
    if (keepActiveLoad && activeSessionLoad) {
      if (allowRetry) activeSessionLoad.overlayReleased = true;
    }
  }

  function setSessionLoading(sessionId, options = {}) {
    clearSessionLoadOverlayTimer();
    clearSessionLoadRequestTimer();
    const loading = !!sessionId;
    const blocking = options.blocking !== false;
    const requestId = loading ? (options.requestId || createSessionSwitchRequestId(sessionId)) : '';
    activeSessionLoad = loading ? {
      sessionId,
      blocking,
      snapshot: null,
      requestId,
      overlayReleased: false,
      recoverCurrent: options.recoverCurrent === true,
    } : null;
    if (loading) scheduleSessionLoadRequestTimeout(sessionId, requestId);
    const showOverlay = !!(loading && blocking);
    if (showOverlay) {
      document.body.classList.add('session-loading-active');
      sessionLoadingOverlay.hidden = false;
      sessionLoadingOverlay.setAttribute('aria-hidden', 'false');
      sessionLoadOverlayTimer = setTimeout(() => {
        sessionLoadOverlayTimer = null;
        if (!activeSessionLoad || activeSessionLoad.sessionId !== sessionId || activeSessionLoad.requestId !== requestId) return;
        releaseSessionLoadingOverlay({ keepActiveLoad: true, allowRetry: true });
      }, SESSION_LOAD_OVERLAY_TIMEOUT_MS);
    } else {
      releaseSessionLoadingOverlay({ keepActiveLoad: loading });
    }
    sessionLoadingLabel.textContent = loading ? (options.label || getSessionLoadLabel(sessionId)) : '正在整理消息与上下文…';
    msgInput.disabled = showOverlay;
    modeSelect.disabled = showOverlay;
    sendBtn.disabled = showOverlay;
    abortBtn.disabled = showOverlay;
    if (showOverlay && document.activeElement instanceof HTMLElement) {
      document.activeElement.blur();
    }
  }

  function clearSessionLoading(sessionId) {
    if (sessionId && activeSessionLoad && activeSessionLoad.sessionId !== sessionId) return;
    setSessionLoading(null, { blocking: false });
  }

  function createSessionSwitchRequestId(sessionId) {
    sessionSwitchRequestSeq += 1;
    return `session-load-${Date.now()}-${sessionSwitchRequestSeq}-${String(sessionId || '').slice(0, 8)}`;
  }

  function isBlockingSessionLoad(sessionId) {
    return !!(activeSessionLoad &&
      activeSessionLoad.blocking &&
      (!sessionId || activeSessionLoad.sessionId === sessionId));
  }

  function finishSessionSwitch(sessionId) {
    if (isBlockingSessionLoad(sessionId)) {
      scrollToBottom();
      requestAnimationFrame(() => clearSessionLoading(sessionId));
      return;
    }
    clearSessionLoading(sessionId);
  }

  function finalizeLoadedSession(sessionId) {
    if (activeSessionLoad?.sessionId === sessionId && activeSessionLoad.snapshot) {
      activeSessionLoad.snapshot.complete = true;
      cacheSessionSnapshot(activeSessionLoad.snapshot);
    }
    finishSessionSwitch(sessionId);
  }

  function beginSessionSwitch(sessionId, options = {}) {
    if (!sessionId) return;
    const blocking = options.blocking !== false;
    const force = options.force === true;
    if (!force && activeSessionLoad?.sessionId === sessionId && !activeSessionLoad.overlayReleased) return;
    if (!force && sessionId === currentSessionId && !activeSessionLoad) return;
    renderEpoch++;
    loadedHistorySessionId = null;
    setSessionLoading(sessionId, { blocking, label: options.label });
    requestSessionLoad(sessionId, { blocking, label: options.label });
  }

  function requestSessionLoad(sessionId, options = {}) {
    if (!sessionId) return;
    pendingSessionSwitchRequest = {
      sessionId,
      blocking: options.blocking !== false,
      label: options.label || '',
      requestId: options.requestId || activeSessionLoad?.requestId || createSessionSwitchRequestId(sessionId),
      recoverCurrent: options.recoverCurrent === true,
    };
    if (ws && ws.readyState === 1 && wsAuthenticated) {
      flushPendingSessionSwitch();
      return;
    }
    if (!ws || ws.readyState > 1) connect();
  }

  function requestSessionResume(sessionId, options = {}) {
    if (!sessionId) return;
    pendingSessionResumeRequest = {
      sessionId,
      requestId: options.requestId || createSessionSwitchRequestId(sessionId),
    };
    if (ws && ws.readyState === 1 && wsAuthenticated) {
      flushPendingSessionResume();
      return;
    }
    if (!ws || ws.readyState > 1) connect();
  }

  function scheduleSessionResumeFallback(sessionId, requestId) {
    clearSessionResumeFallbackTimer();
    if (!sessionId || !requestId) return;
    sessionResumeFallbackTimer = setTimeout(() => {
      sessionResumeFallbackTimer = null;
      if (!pendingSessionResumeRequest ||
        pendingSessionResumeRequest.sessionId !== sessionId ||
        pendingSessionResumeRequest.requestId !== requestId) {
        return;
      }
      pendingSessionResumeRequest = null;
      requestSessionLoad(sessionId, {
        blocking: false,
        label: '正在恢复运行输出…',
        requestId: createSessionSwitchRequestId(sessionId),
        recoverCurrent: true,
      });
    }, SESSION_RESUME_FALLBACK_MS);
  }

  function flushPendingSessionSwitch() {
    if (!pendingSessionSwitchRequest) return false;
    if (!ws || ws.readyState !== 1 || !wsAuthenticated) return false;
    const request = pendingSessionSwitchRequest;
    pendingSessionSwitchRequest = null;
    if (!activeSessionLoad) {
      setSessionLoading(request.sessionId, {
        blocking: request.blocking,
        label: request.label || undefined,
        requestId: request.requestId,
        recoverCurrent: request.recoverCurrent === true,
      });
    }
    ws.send(JSON.stringify({ type: 'load_session', sessionId: request.sessionId, requestId: request.requestId }));
    return true;
  }

  function flushPendingSessionResume() {
    if (!pendingSessionResumeRequest) return false;
    if (!ws || ws.readyState !== 1 || !wsAuthenticated) return false;
    const request = pendingSessionResumeRequest;
    ws.send(JSON.stringify({ type: 'resume_session', sessionId: request.sessionId, requestId: request.requestId }));
    scheduleSessionResumeFallback(request.sessionId, request.requestId);
    return true;
  }

  function showCachedSession(sessionId) {
    const snapshot = buildCachedSessionSnapshot(sessionId);
    if (!snapshot) return false;
    if (!isPrimaryUiAgent(snapshot.agent)) return false;
    if (currentSessionId && currentSessionId !== sessionId) {
      send({ type: 'detach_view' });
    }
    closeUserOutlinePanel();
    closeCcwebPromptOutlinePanel();
    clearSessionLoading();
    touchSessionCache(sessionId);
    return applySessionSnapshot(snapshot, { immediate: true, suppressUnreadToast: true });
  }

  function openSession(sessionId, options = {}) {
    if (!sessionId) return;
    const meta = getSessionMeta(sessionId);
    const cachedAgent = sessionCache.get(sessionId)?.snapshot?.agent;
    if ((meta && !isPrimaryUiAgent(meta.agent)) || (!meta && cachedAgent && !isPrimaryUiAgent(cachedAgent))) {
      renderSessionList();
      return;
    }
    closeUserOutlinePanel();
    closeCcwebPromptOutlinePanel();
    if (options.forceSync) {
      beginSessionSwitch(sessionId, { blocking: options.blocking !== false, force: true, label: options.label });
      return;
    }
    if (!options.force && sessionId === currentSessionId && !activeSessionLoad) return;

    const disposition = getSessionCacheDisposition(sessionId);
    if (disposition === 'strong') {
      showCachedSession(sessionId);
      return;
    }
    if (disposition === 'weak' && showCachedSession(sessionId)) {
      beginSessionSwitch(sessionId, { blocking: false, force: true, label: options.label });
      return;
    }
    beginSessionSwitch(sessionId, { blocking: options.blocking !== false, force: options.force === true, label: options.label });
  }

  function setStatsDisplay(msg) {
    if (isCodexLikeAgent(currentAgent) && msg && msg.totalUsage) {
      const usage = msg.totalUsage;
      if ((usage.inputTokens || 0) > 0 || (usage.outputTokens || 0) > 0) {
        const cacheText = usage.cachedInputTokens ? ` · cache ${usage.cachedInputTokens}` : '';
        costDisplay.textContent = `in ${usage.inputTokens} · out ${usage.outputTokens}${cacheText}`;
        return;
      }
    }
    if (msg && typeof msg.totalCost === 'number' && msg.totalCost > 0) {
      costDisplay.textContent = `$${msg.totalCost.toFixed(4)}`;
      return;
    }
    costDisplay.textContent = '';
  }

	  function _splitCodexThinkingModel(model) {
	    const raw = String(model || '').trim();
	    if (!raw) return { base: '', level: '' };
	    const m = raw.match(/^(.*)\(([^()]+)\)\s*$/);
	    if (!m) return { base: raw, level: '' };
	    return { base: (m[1] || '').trim(), level: (m[2] || '').trim().toLowerCase() };
	  }

	  function _isCodexModelAtLeast52(model) {
	    const { base } = _splitCodexThinkingModel(model);
	    // Accept only GPT-5.2+ (hide/remove older and other families from picker).
	    const m = String(base || '').trim().match(/^gpt-5\.(\d+)(?:-.+)?$/i);
	    if (!m) return false;
	    const minor = Number(m[1] || 0);
	    return Number.isFinite(minor) && minor >= 2;
	  }

	  function getCodexBaseModelOptions() {
	    const seen = new Set();
	    const options = [];

	    function addOption(value, label, desc) {
	      const v = (value || '').trim();
	      if (!v || seen.has(v)) return;
	      seen.add(v);
	      options.push({ value: v, label: label || v, desc: desc || 'Codex 模型' });
	    }

	    function addBaseOption(value, label, desc) {
	      if (!_isCodexModelAtLeast52(value)) return;
	      const { base } = _splitCodexThinkingModel(value);
	      addOption(base, label || base, desc);
	    }

	    DEFAULT_CODEX_MODEL_OPTIONS.forEach((opt) => addBaseOption(opt.value, opt.label, opt.desc));
	    addBaseOption(currentModel, currentModel, '当前会话模型');
	    sessions
      .filter((s) => isCodexLikeAgent(s.agent) && s.id === currentSessionId)
	      .forEach((s) => addBaseOption(s.model, s.model, '当前会话已保存模型'));

	    return options;
	  }

  // --- marked config ---
  const PREVIEW_LANGS = new Set(['html', 'svg']);
  const MERMAID_LANGS = new Set(['mermaid', 'mmd']);
  const _previewCodeMap = new Map();
  let _previewCodeId = 0;
  let _mermaidInitialized = false;
  let _mermaidRenderId = 0;

  function normalizeCodeLanguage(language) {
    const lang = String(language || '').trim().split(/\s+/)[0].toLowerCase();
    return lang || 'plaintext';
  }

  function highlightCodeBlock(code, lang) {
    try {
      if (hljs.getLanguage(lang)) {
        return hljs.highlight(code, { language: lang }).value;
      }
      return hljs.highlightAuto(code).value;
    } catch {
      return escapeHtml(code);
    }
  }

  function getCodeBlockSource(wrapper) {
    if (!wrapper) return '';
    const cid = wrapper.dataset.cid ? Number(wrapper.dataset.cid) : 0;
    if (cid && _previewCodeMap.has(cid)) return _previewCodeMap.get(cid);
    return wrapper.querySelector('code')?.textContent || '';
  }

  function createStoredCodeId(code) {
    const cid = ++_previewCodeId;
    _previewCodeMap.set(cid, code);
    return cid;
  }

  function renderMermaidCodeBlock(code, lang) {
    const cid = createStoredCodeId(code);
    const highlighted = highlightCodeBlock(code, lang);
    return `<div class="code-block-wrapper mermaid-code-block mermaid-diagram-mode" data-cid="${cid}">
      <div class="code-block-header">
        <span>${escapeHtml(lang)}</span>
        <div class="code-block-actions">
          <button class="code-preview-btn mermaid-toggle-btn" type="button" onclick="ccToggleMermaid(this, event)" aria-label="切换 Mermaid 图形与代码">代码</button>
          <button class="code-preview-btn mermaid-copy-graph-btn" type="button" onclick="ccCopyMermaidDiagram(this, event)" aria-label="复制 Mermaid 图形">复制图</button>
          <button class="code-preview-btn mermaid-download-btn" type="button" onclick="ccDownloadMermaidDiagram(this, event)" aria-label="下载 Mermaid 图形">下载图</button>
          <button class="code-copy-btn" type="button" onclick="ccCopyCode(this, event)" aria-label="复制 Mermaid 代码">Copy</button>
        </div>
      </div>
      <div class="mermaid-render-pane" data-render-state="pending">
        <div class="mermaid-render-target" aria-label="Mermaid 图形"></div>
        <div class="mermaid-render-error" role="alert" hidden></div>
      </div>
      <pre><code class="hljs language-${escapeHtml(lang)}">${highlighted}</code></pre>
    </div>`;
  }

  const renderer = new marked.Renderer();
  renderer.code = function (code, language) {
    const source = String(code || '');
    const lang = normalizeCodeLanguage(language);
    if (MERMAID_LANGS.has(lang)) return renderMermaidCodeBlock(source, lang);

    const highlighted = highlightCodeBlock(source, lang);
    const canPreview = PREVIEW_LANGS.has(lang);
    const previewBtn = canPreview
      ? `<button class="code-preview-btn" type="button" onclick="ccTogglePreview(this, event)" aria-label="预览代码块">Preview</button>`
      : '';
    const previewPane = canPreview
      ? `<div class="code-preview-pane"><iframe class="code-preview-iframe" sandbox="allow-scripts" loading="lazy"></iframe></div>`
      : '';
    const cid = canPreview ? createStoredCodeId(source) : 0;
    return `<div class="code-block-wrapper${canPreview ? ' has-preview' : ''}"${canPreview ? ` data-cid="${cid}"` : ''}>
      <div class="code-block-header">
        <span>${escapeHtml(lang)}</span>
        <div class="code-block-actions">${previewBtn}<button class="code-copy-btn" type="button" onclick="ccCopyCode(this, event)" aria-label="复制代码块">Copy</button></div>
      </div>
      ${previewPane}<pre><code class="hljs language-${escapeHtml(lang)}">${highlighted}</code></pre>
    </div>`;
  };
  marked.setOptions({ renderer, breaks: true, gfm: true });

  window.ccCopyCode = async function (btn, event) {
    event?.preventDefault();
    event?.stopPropagation();
    const wrapper = btn?.closest?.('.code-block-wrapper');
    if (!wrapper) return;
    const code = getCodeBlockSource(wrapper);
    const defaultLabel = btn.dataset.defaultLabel || btn.textContent || 'Copy';
    btn.dataset.defaultLabel = defaultLabel;
    btn.disabled = true;
    const copied = await copyTextToClipboard(code, '代码已复制');
    btn.disabled = false;
    if (!copied) return;
    if (btn._copyResetTimer) clearTimeout(btn._copyResetTimer);
    btn.textContent = 'Copied!';
    btn._copyResetTimer = setTimeout(() => {
      btn.textContent = btn.dataset.defaultLabel || 'Copy';
      btn._copyResetTimer = null;
    }, 1500);
  };

  window.ccTogglePreview = function (btn, event) {
    event?.preventDefault();
    event?.stopPropagation();
    const wrapper = btn.closest('.code-block-wrapper');
    const inPreview = wrapper.classList.contains('preview-mode');
    if (inPreview) {
      wrapper.classList.remove('preview-mode');
      btn.textContent = 'Preview';
    } else {
      const iframe = wrapper.querySelector('.code-preview-iframe');
      if (iframe && !iframe.dataset.loaded) {
        const cid = wrapper.dataset.cid ? Number(wrapper.dataset.cid) : 0;
        iframe.srcdoc = (cid && _previewCodeMap.has(cid)) ? _previewCodeMap.get(cid) : '';
        iframe.dataset.loaded = '1';
      }
      wrapper.classList.add('preview-mode');
      btn.textContent = 'Source';
    }
  };

  function ensureMermaidInitialized() {
    if (!window.mermaid?.render) throw new Error('Mermaid 加载失败');
    if (!_mermaidInitialized) {
      window.mermaid.initialize({
        startOnLoad: false,
        securityLevel: 'strict',
        theme: 'default',
      });
      _mermaidInitialized = true;
    }
    return window.mermaid;
  }

  function setMermaidRenderError(wrapper, message) {
    const pane = wrapper?.querySelector?.('.mermaid-render-pane');
    const errorEl = wrapper?.querySelector?.('.mermaid-render-error');
    if (!pane || !errorEl) return;
    pane.dataset.renderState = 'error';
    errorEl.hidden = false;
    errorEl.textContent = message || 'Mermaid 图形渲染失败';
  }

  async function hydrateMermaidBlock(wrapper, force = false) {
    if (!wrapper || (!force && wrapper.dataset.mermaidRendered === '1')) return true;
    const pane = wrapper.querySelector('.mermaid-render-pane');
    const target = wrapper.querySelector('.mermaid-render-target');
    const errorEl = wrapper.querySelector('.mermaid-render-error');
    if (!pane || !target) return false;

    const code = getCodeBlockSource(wrapper).trim();
    if (!code) {
      setMermaidRenderError(wrapper, 'Mermaid 代码为空');
      return false;
    }

    const renderKey = String(++_mermaidRenderId);
    wrapper.dataset.mermaidRenderKey = renderKey;
    wrapper.dataset.mermaidRendered = '0';
    pane.dataset.renderState = 'pending';
    target.innerHTML = '';
    if (errorEl) {
      errorEl.hidden = true;
      errorEl.textContent = '';
    }

    try {
      const mermaidApi = ensureMermaidInitialized();
      const result = await mermaidApi.render(`ccweb-mermaid-${renderKey}`, code);
      if (!wrapper.isConnected || wrapper.dataset.mermaidRenderKey !== renderKey) return false;
      target.innerHTML = typeof result === 'string' ? result : result.svg;
      if (typeof result?.bindFunctions === 'function') result.bindFunctions(target);
      pane.dataset.renderState = 'rendered';
      wrapper.dataset.mermaidRendered = '1';
      return true;
    } catch (error) {
      if (!wrapper.isConnected || wrapper.dataset.mermaidRenderKey !== renderKey) return false;
      setMermaidRenderError(wrapper, error?.message || 'Mermaid 图形渲染失败');
      return false;
    }
  }

  function hydrateMermaidBlocks(root) {
    if (!root) return;
    const blocks = root.matches?.('.mermaid-code-block')
      ? [root]
      : Array.from(root.querySelectorAll('.mermaid-code-block'));
    blocks.forEach((block) => {
      hydrateMermaidBlock(block);
    });
  }

  function handleLocalFileLinkClick(event) {
    event.preventDefault();
    event.stopPropagation();

    const link = event.currentTarget;
    const filePath = link?.dataset?.filePath || '';
    const line = Number(link?.dataset?.fileLine || 0) || 0;
    const relativePath = getWorkspaceRelativePath(filePath);
    if (!relativePath) {
      showToast(currentCwd ? '文件不在当前会话工作目录内' : '当前会话没有可浏览的工作目录');
      return;
    }

    showFileBrowser();
    if (!fileBrowserState) return;

    loadFileBrowserDirectory(getBrowserParentPath(relativePath), { preservePreview: true });
    openFileBrowserFile(relativePath, { line });
  }

  function hydrateLocalFileLinks(root) {
    if (!root) return;
    const links = root.matches?.('a')
      ? [root]
      : Array.from(root.querySelectorAll('a'));

    links.forEach((link) => {
      if (link.dataset.localFileHydrated === '1') return;
      const parsed = parseLocalFileLinkHref(link.getAttribute('href') || '');
      if (!parsed) return;

      link.dataset.localFileHydrated = '1';
      link.dataset.localFileLink = 'true';
      link.dataset.filePath = parsed.filePath;
      link.dataset.fileLine = parsed.line ? String(parsed.line) : '';
      link.classList.add('local-file-link');
      link.title = parsed.line ? `${parsed.filePath}:${parsed.line}` : parsed.filePath;
      link.textContent = formatLocalFileLinkText(parsed, link.textContent);
      link.addEventListener('click', handleLocalFileLinkClick);
    });
  }

  function hydrateRenderedMarkdown(root) {
    hydrateLocalFileLinks(root);
    hydrateMermaidBlocks(root);
  }

  function setRenderedMarkdown(target, text, hydrate = true) {
    target.innerHTML = renderMarkdown(text);
    if (hydrate) hydrateRenderedMarkdown(target);
  }

  window.ccToggleMermaid = function (btn, event) {
    event?.preventDefault();
    event?.stopPropagation();
    const wrapper = btn?.closest?.('.mermaid-code-block');
    if (!wrapper) return;
    const showCode = wrapper.classList.contains('mermaid-diagram-mode');
    wrapper.classList.toggle('mermaid-diagram-mode', !showCode);
    wrapper.classList.toggle('mermaid-source-mode', showCode);
    btn.textContent = showCode ? '图' : '代码';
    if (!showCode) hydrateMermaidBlock(wrapper);
  };

  function serializeMermaidSvg(wrapper) {
    const svg = wrapper?.querySelector?.('.mermaid-render-target svg');
    if (!svg) return '';
    const clone = svg.cloneNode(true);
    if (!clone.getAttribute('xmlns')) clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
    if (!clone.getAttribute('width') || !clone.getAttribute('height')) {
      const viewBox = clone.getAttribute('viewBox');
      const parts = viewBox ? viewBox.trim().split(/\s+/).map(Number) : [];
      if (parts.length === 4 && parts.every(Number.isFinite)) {
        clone.setAttribute('width', String(Math.max(1, Math.ceil(parts[2]))));
        clone.setAttribute('height', String(Math.max(1, Math.ceil(parts[3]))));
      }
    }
    return new XMLSerializer().serializeToString(clone);
  }

  async function getMermaidSvgText(wrapper) {
    if (!serializeMermaidSvg(wrapper)) {
      await hydrateMermaidBlock(wrapper, true);
    }
    return serializeMermaidSvg(wrapper);
  }

  function svgTextToPngBlob(svgText) {
    return new Promise((resolve, reject) => {
      const svgBlob = new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' });
      const url = URL.createObjectURL(svgBlob);
      const image = new Image();
      image.onload = () => {
        try {
          const canvas = document.createElement('canvas');
          const width = Math.max(1, image.naturalWidth || image.width || 1200);
          const height = Math.max(1, image.naturalHeight || image.height || 800);
          canvas.width = width;
          canvas.height = height;
          const ctx = canvas.getContext('2d');
          ctx.fillStyle = '#ffffff';
          ctx.fillRect(0, 0, width, height);
          ctx.drawImage(image, 0, 0, width, height);
          canvas.toBlob((blob) => {
            URL.revokeObjectURL(url);
            if (blob) resolve(blob);
            else reject(new Error('PNG 导出失败'));
          }, 'image/png');
        } catch (error) {
          URL.revokeObjectURL(url);
          reject(error);
        }
      };
      image.onerror = () => {
        URL.revokeObjectURL(url);
        reject(new Error('SVG 图形读取失败'));
      };
      image.src = url;
    });
  }

  async function copyBlobToClipboard(blob, type) {
    if (!navigator.clipboard?.write || typeof ClipboardItem === 'undefined' || !window.isSecureContext) return false;
    await navigator.clipboard.write([new ClipboardItem({ [type]: blob })]);
    return true;
  }

  function setTemporaryButtonLabel(btn, label, fallback, timeout = 1500) {
    if (!btn) return;
    const defaultLabel = btn.dataset.defaultLabel || fallback || btn.textContent || '';
    btn.dataset.defaultLabel = defaultLabel;
    if (btn._labelResetTimer) clearTimeout(btn._labelResetTimer);
    btn.textContent = label;
    btn._labelResetTimer = setTimeout(() => {
      btn.textContent = btn.dataset.defaultLabel || defaultLabel;
      btn._labelResetTimer = null;
    }, timeout);
  }

  window.ccCopyMermaidDiagram = async function (btn, event) {
    event?.preventDefault();
    event?.stopPropagation();
    const wrapper = btn?.closest?.('.mermaid-code-block');
    if (!wrapper) return;
    btn.disabled = true;
    try {
      const svgText = await getMermaidSvgText(wrapper);
      if (!svgText) throw new Error('暂无可复制图形');
      let copied = false;
      try {
        const pngBlob = await svgTextToPngBlob(svgText);
        copied = await copyBlobToClipboard(pngBlob, 'image/png');
      } catch {}
      if (!copied) {
        const svgBlob = new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' });
        try { copied = await copyBlobToClipboard(svgBlob, 'image/svg+xml'); } catch {}
      }
      if (copied) {
        showToast('图形已复制');
        setTemporaryButtonLabel(btn, '已复制', '复制图');
      } else {
        await copyTextToClipboard(svgText, '图形 SVG 已复制');
        setTemporaryButtonLabel(btn, '已复制', '复制图');
      }
    } catch (error) {
      showToast(error?.message || '复制图形失败');
    } finally {
      btn.disabled = false;
    }
  };

  function downloadBlob(blob, filename) {
    const url = URL.createObjectURL(blob);
    const link = document.createElement('a');
    link.href = url;
    link.download = filename;
    link.rel = 'noopener';
    document.body.appendChild(link);
    link.click();
    link.remove();
    setTimeout(() => URL.revokeObjectURL(url), 1000);
  }

  window.ccDownloadMermaidDiagram = async function (btn, event) {
    event?.preventDefault();
    event?.stopPropagation();
    const wrapper = btn?.closest?.('.mermaid-code-block');
    if (!wrapper) return;
    btn.disabled = true;
    try {
      const svgText = await getMermaidSvgText(wrapper);
      if (!svgText) throw new Error('暂无可下载图形');
      const cid = wrapper.dataset.cid || Date.now();
      const blob = new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' });
      downloadBlob(blob, `mermaid-${cid}.svg`);
      showToast('图形已下载');
      setTemporaryButtonLabel(btn, '已下载', '下载图');
    } catch (error) {
      showToast(error?.message || '下载图形失败');
    } finally {
      btn.disabled = false;
    }
  };

  // --- WebSocket ---
  function isBrowserOnline() {
    return !('onLine' in navigator) || navigator.onLine;
  }

  function canConnectWs() {
    return !isPageUnloading && isBrowserOnline();
  }

  function clearReconnectTimer() {
    if (!reconnectTimer) return;
    clearTimeout(reconnectTimer);
    reconnectTimer = null;
  }

  function connect() {
    if (!canConnectWs()) return;
    if (ws && ws.readyState <= 1) return;
    clearReconnectTimer();

    const socket = new WebSocket(WS_URL);
    ws = socket;
    wsAuthenticated = false;

    socket.onopen = () => {
      if (ws !== socket) return;
      reconnectAttempts = 0;
      clearReconnectTimer();
      if (authToken) send({ type: 'auth', token: authToken });
    };

    socket.onmessage = (e) => {
      if (ws !== socket) return;
      let msg;
      try { msg = JSON.parse(e.data); } catch { return; }
      try {
        handleServerMessage(msg);
      } catch (err) {
        console.error('[cc-web] failed to handle server message', err, msg);
        if (activeSessionLoad) {
          releaseSessionLoadingOverlay({ keepActiveLoad: true, allowRetry: true });
        }
      }
    };

    socket.onclose = () => {
      if (ws !== socket) return;
      ws = null;
      wsAuthenticated = false;
      if (activeSessionLoad?.sessionId && !isPageUnloading) {
        pendingSessionSwitchRequest = {
          sessionId: activeSessionLoad.sessionId,
          blocking: false,
          label: sessionLoadingLabel?.textContent || '',
          requestId: activeSessionLoad.requestId || createSessionSwitchRequestId(activeSessionLoad.sessionId),
          recoverCurrent: activeSessionLoad.recoverCurrent === true,
        };
      } else if (currentSessionId && (isGenerating || currentSessionRunning) && !isPageUnloading) {
        pendingSessionResumeRequest = {
          sessionId: currentSessionId,
          requestId: createSessionSwitchRequestId(currentSessionId),
        };
      }
      clearSessionLoading();
      scheduleReconnect();
    };
    socket.onerror = () => {};
  }

  function send(data) {
    if (ws && ws.readyState === 1) ws.send(JSON.stringify(data));
  }

  function scheduleReconnect() {
    if (!canConnectWs()) return;
    if (reconnectTimer) return;
    const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
    reconnectAttempts++;
    reconnectTimer = setTimeout(() => {
      reconnectTimer = null;
      connect();
    }, delay);
  }

  // --- Server Message Handler ---
  function isCurrentSessionEvent(msg) {
    return !msg.sessionId || msg.sessionId === currentSessionId;
  }

  function ensureGeneratingForEvent(msg) {
    if (!isCurrentSessionEvent(msg)) return false;
    const targetSessionId = msg.sessionId || currentSessionId || null;
    if (!isGenerating || generatingSessionId !== targetSessionId || !document.getElementById('streaming-msg')) {
      return startGenerating(targetSessionId);
    }
    return true;
  }

  function handleServerMessage(msg) {
    switch (msg.type) {
      case 'auth_result':
        if (msg.success) {
          const shouldLoadInitialSession = !initialSessionListHandled && !currentSessionId;
          authToken = msg.token;
          wsAuthenticated = true;
          localStorage.setItem('cc-web-token', msg.token);
          document.dispatchEvent(new CustomEvent('cc-web-auth-restored'));
          loginOverlay.hidden = true;
          app.hidden = false;
          const flushedSessionSwitch = flushPendingSessionSwitch();
          const flushedSessionResume = flushedSessionSwitch ? false : flushPendingSessionResume();
          if (!flushedSessionSwitch &&
            !flushedSessionResume &&
            !pendingSessionSwitchRequest &&
            !pendingSessionResumeRequest &&
            currentSessionId &&
            (isGenerating || currentSessionRunning)) {
            requestSessionResume(currentSessionId, {
              requestId: createSessionSwitchRequestId(currentSessionId),
            });
          }
          send({ type: 'get_codex_config' });
          // Check if must change password
          if (msg.mustChangePassword) {
            showForceChangePassword();
          } else {
            pendingInitialSessionLoad = shouldLoadInitialSession;
          }
        } else {
          pendingSessionSwitchRequest = null;
          pendingSessionResumeRequest = null;
          clearSessionResumeFallbackTimer();
          clearSessionLoading();
          authToken = null;
          wsAuthenticated = false;
          localStorage.removeItem('cc-web-token');
          document.dispatchEvent(new CustomEvent('cc-web-auth-failed'));
          loginOverlay.hidden = false;
          app.hidden = true;
          if (msg.banned) {
            loginError.textContent = '该 IP 已被永久封禁';
            loginError.hidden = false;
            loginPassword.disabled = true;
            loginForm.querySelector('button[type="submit"]').disabled = true;
          } else {
            loginError.textContent = '密码错误';
            loginError.hidden = false;
          }
        }
        break;

      case 'session_list':
        sessions = Array.isArray(msg.sessions) ? msg.sessions.map(normalizeSessionSnapshot) : [];
        reconcileSessionCacheWithSessions();
        renderSessionList();
        if (currentSessionId) {
          setCurrentSessionRunningState(!!getSessionMeta(currentSessionId)?.isRunning);
        }
        if (pendingInitialSessionLoad) {
          pendingInitialSessionLoad = false;
          initialSessionListHandled = true;
          syncViewForAgent(currentAgent, { preserveCurrent: false, loadLast: true });
        } else if (currentSessionId && !getSessionMeta(currentSessionId)) {
          initialSessionListHandled = true;
          resetChatView(currentAgent);
        } else {
          initialSessionListHandled = true;
        }
        break;

      case 'session_info':
        const snapshot = normalizeSessionSnapshot(msg);
        const activeLoad = activeSessionLoad;
        const pendingNewSession = pendingNewSessionRequest;
        const messageRequestId = String(msg.requestId || '');
        const looksLikeCreatedSession = Array.isArray(msg.messages)
          && msg.messages.length === 0
          && !msg.historyPending
          && !msg.isRunning;
        const matchesPendingNewSessionFallback = !!(pendingNewSession
          && !messageRequestId
          && looksLikeCreatedSession
          && snapshot.sessionId
          && snapshot.sessionId !== currentSessionId
          && snapshot.agent === pendingNewSession.agent
          && (!pendingNewSession.cwd || snapshot.cwd === pendingNewSession.cwd)
          && (!pendingNewSession.mode || snapshot.mode === pendingNewSession.mode));
        const matchesActiveLoad = !!(activeLoad?.sessionId === msg.sessionId
          && (!activeLoad.requestId || activeLoad.requestId === messageRequestId));
        const matchesPendingNewSession = !!(pendingNewSession
          && ((!pendingNewSession.requestId || pendingNewSession.requestId === messageRequestId)
            || matchesPendingNewSessionFallback));
        const canSwitchToSessionInfo = matchesActiveLoad
          || matchesPendingNewSession
          || msg.sessionId === currentSessionId
          || (!messageRequestId && !currentSessionId && !activeLoad && !pendingNewSession)
          || (!messageRequestId && !activeLoad && !pendingNewSession);
        mergeSessionListSnapshot(snapshot);
        if (matchesActiveLoad) {
          activeSessionLoad.snapshot = snapshot;
        }
        if (!canSwitchToSessionInfo) {
          if (!msg.historyPending) cacheSessionSnapshot(snapshot);
          renderSessionList();
          break;
        }
        if (matchesPendingNewSession) {
          pendingNewSessionRequest = null;
          if (!matchesActiveLoad) clearSessionLoading();
        }
        const appliedSnapshot = applySessionSnapshot(snapshot, {
          immediate: isBlockingSessionLoad(msg.sessionId),
          suppressUnreadToast: false,
          preserveStreaming: msg.sessionId === currentSessionId && msg.isRunning,
        });
        if (!appliedSnapshot) {
          if (!msg.historyPending) cacheSessionSnapshot(snapshot);
          if (matchesActiveLoad) clearSessionLoading(msg.sessionId);
          break;
        }
        if (msg.sessionId === currentSessionId) {
          setCurrentSessionRunningState(!!msg.isRunning);
        }
        if (!msg.historyPending) {
          if (matchesActiveLoad) {
            finalizeLoadedSession(msg.sessionId);
          } else {
            cacheSessionSnapshot(snapshot);
            finishSessionSwitch(msg.sessionId);
          }
        }
        break;

      case 'session_history_chunk':
        if (activeSessionLoad?.recoverCurrent && activeSessionLoad.sessionId === msg.sessionId) {
          if (!msg.remaining) finalizeLoadedSession(msg.sessionId);
          break;
        }
        if (msg.sessionId === currentSessionId && loadedHistorySessionId === msg.sessionId) {
          const blocking = isBlockingSessionLoad(msg.sessionId);
          if (activeSessionLoad?.sessionId === msg.sessionId && activeSessionLoad.snapshot) {
            activeSessionLoad.snapshot.messages = cloneMessages(msg.messages || []).concat(activeSessionLoad.snapshot.messages);
          }
          prependHistoryMessages(msg.messages || [], {
            preserveScroll: !blocking,
            skipScrollbar: blocking,
            baseIndex: Number.isFinite(Number(msg.historyBaseIndex)) ? Number(msg.historyBaseIndex) : 0,
          });
          if (!msg.remaining) {
            finalizeLoadedSession(msg.sessionId);
          }
        }
        break;

      case 'session_message':
        if (msg.sessionId && msg.message) {
          const isCrossConversationReply = !!msg.message.crossConversation?.replyToRequestId;
          updateCachedSession(msg.sessionId, (snapshot) => {
            snapshot.messages = Array.isArray(snapshot.messages) ? snapshot.messages : [];
            snapshot.messages.push(deepClone(msg.message));
            snapshot.updated = msg.message.timestamp || new Date().toISOString();
            if (isCrossConversationReply) {
              snapshot.readyReplyCount = Math.max(0, Number(snapshot.readyReplyCount || 0) - 1);
              snapshot.pendingReplyCount = Math.max(0, Number(snapshot.pendingReplyCount || 0) - 1);
              snapshot.waitingOnChildren = Number(snapshot.pendingReplyCount || 0) > 0;
            }
          });
          if (isCrossConversationReply) {
            sessions = sessions.map((session) => {
              if (session.id !== msg.sessionId) return session;
              const pendingReplyCount = Math.max(0, Number(session.pendingReplyCount || 0) - 1);
              const readyReplyCount = Math.max(0, Number(session.readyReplyCount || 0) - 1);
              return {
                ...session,
                readyReplyCount,
                pendingReplyCount,
                waitingOnChildren: pendingReplyCount > 0,
              };
            });
          }
        }
        if (msg.sessionId === currentSessionId && msg.message) {
          const messageIndex = currentSessionMessageCount;
          currentSessionMessageCount += 1;
          collectClosedCollabAgentIds([msg.message]).forEach((id) => closedCollabAgentIds.add(id));
          const welcome = messagesDiv.querySelector('.welcome-msg');
          if (welcome) welcome.remove();
          const shouldFollow = !(currentSessionRunning || isGenerating) || isNearBottom();
          messagesDiv.appendChild(buildMsgElement(msg.message, messageIndex));
          followOutputIfNeeded(shouldFollow);
          setCurrentSessionRunningState(!!getSessionMeta(currentSessionId)?.isRunning);
          renderPendingCcwebPrompts({ scroll: false });
        }
        renderSessionList();
        break;

      case 'session_renamed':
        sessions = sessions.map((session) => session.id === msg.sessionId ? {
          ...session,
          title: msg.title,
          ...(msg.titleSource !== undefined ? { titleSource: msg.titleSource || null } : {}),
          ...(msg.createdFromKind !== undefined ? { createdFromKind: msg.createdFromKind || null } : {}),
        } : session);
        updateCachedSession(msg.sessionId, (snapshot) => {
          snapshot.title = msg.title;
          if (msg.titleSource !== undefined) snapshot.titleSource = msg.titleSource || null;
          if (msg.createdFromKind !== undefined) snapshot.createdFromKind = msg.createdFromKind || null;
        });
        if (msg.sessionId === currentSessionId) {
          chatTitle.textContent = msg.title;
        }
        renderSessionList();
        break;

      case 'session_pinned':
        applySessionPinnedState(msg.sessionId, msg.pinnedAt || null);
        break;

      case 'text_delta':
        if (!ensureGeneratingForEvent(msg)) break;
        pendingText += msg.text;
        scheduleRender();
        break;

      case 'content_blocks':
        if (!ensureGeneratingForEvent(msg)) break;
        if (Array.isArray(msg.blocks)) {
          if (!window.pendingContentBlocks) window.pendingContentBlocks = [];
          window.pendingContentBlocks.push(...msg.blocks);
          scheduleRender();
        }
        break;

      case 'tool_start':
        if (!ensureGeneratingForEvent(msg)) break;
        if (isEmptyReasoningTool({ name: msg.name, input: msg.input, kind: msg.kind || null, meta: msg.meta || null, done: false })) break;
        activeToolCalls.set(msg.toolUseId, { name: msg.name, input: msg.input, kind: msg.kind || null, meta: msg.meta || null, done: false });
        appendToolCall(msg.toolUseId, msg.name, msg.input, false, msg.kind || null, msg.meta || null);
        break;

      case 'tool_update':
        if (!ensureGeneratingForEvent(msg)) break;
        if (!activeToolCalls.has(msg.toolUseId)) {
          activeToolCalls.set(msg.toolUseId, {
            name: msg.name,
            input: msg.input,
            kind: msg.kind || null,
            meta: msg.meta || null,
            result: msg.result,
            done: false,
          });
          appendToolCall(msg.toolUseId, msg.name, msg.input, false, msg.kind || null, msg.meta || null, msg.result);
        }
        activeToolCalls.get(msg.toolUseId).done = false;
        if (msg.name) activeToolCalls.get(msg.toolUseId).name = msg.name;
        if (msg.input !== undefined) activeToolCalls.get(msg.toolUseId).input = msg.input;
        if (msg.kind) activeToolCalls.get(msg.toolUseId).kind = msg.kind;
        if (msg.meta) activeToolCalls.get(msg.toolUseId).meta = msg.meta;
        activeToolCalls.get(msg.toolUseId).result = msg.result;
        updateToolCall(msg.toolUseId, msg.result, false);
        break;

      case 'tool_end':
        if (!isCurrentSessionEvent(msg)) break;
        if (!activeToolCalls.has(msg.toolUseId) && !isEmptyReasoningTool({ name: msg.name, input: msg.input, result: msg.result, kind: msg.kind || null, meta: msg.meta || null, done: true })) {
          activeToolCalls.set(msg.toolUseId, {
            name: msg.name,
            input: msg.input,
            kind: msg.kind || null,
            meta: msg.meta || null,
            result: msg.result,
            done: true,
          });
          appendToolCall(msg.toolUseId, msg.name, msg.input, true, msg.kind || null, msg.meta || null, msg.result);
        }
        if (activeToolCalls.has(msg.toolUseId)) {
          activeToolCalls.get(msg.toolUseId).done = true;
          if (msg.kind) activeToolCalls.get(msg.toolUseId).kind = msg.kind;
          if (msg.meta) activeToolCalls.get(msg.toolUseId).meta = msg.meta;
          activeToolCalls.get(msg.toolUseId).result = msg.result;
        }
        updateToolCall(msg.toolUseId, msg.result, true);
        break;

      case 'cost':
        if (!isCurrentSessionEvent(msg)) break;
        costDisplay.textContent = `$${msg.costUsd.toFixed(4)}`;
        if (currentSessionId) {
          updateCachedSession(currentSessionId, (snapshot) => { snapshot.totalCost = msg.costUsd; });
        }
        break;

      case 'usage':
        if (!isCurrentSessionEvent(msg)) break;
        if (msg.totalUsage) {
          const cacheText = msg.totalUsage.cachedInputTokens ? ` · cache ${msg.totalUsage.cachedInputTokens}` : '';
          costDisplay.textContent = `in ${msg.totalUsage.inputTokens} · out ${msg.totalUsage.outputTokens}${cacheText}`;
          if (currentSessionId) {
            updateCachedSession(currentSessionId, (snapshot) => { snapshot.totalUsage = deepClone(msg.totalUsage); });
          }
        }
        break;

      case 'done':
        if (!isCurrentSessionEvent(msg)) {
          if (msg.sessionId) {
            updateCachedSession(msg.sessionId, (snapshot) => { snapshot.isRunning = false; });
          }
          break;
        }
        finishGenerating(msg.sessionId);
        break;

      case 'system_message':
        if (!isCurrentSessionEvent(msg)) break;
        applyPendingSlashDraftResponse(msg);
        appendSystemMessage(msg.message, {
          tone: msg.tone,
          transient: msg.transient,
          autoDismissMs: msg.autoDismissMs,
        });
        break;

      case 'codex_app_steer_status':
        if (!isCurrentSessionEvent(msg)) break;
        updateCodexAppSteerMessage(msg.clientMessageId, msg.status, msg.message);
        break;

      case 'codex_app_user_input_request':
        if (msg.sessionId && msg.sessionId !== currentSessionId) {
          showToast('Codex App 需要输入', msg.sessionId);
        }
        showCodexAppUserInputModal(msg);
        break;

      case 'codex_app_approval_request':
        if (msg.sessionId && msg.sessionId !== currentSessionId) {
          showToast('Codex App 需要审批', msg.sessionId);
        }
        showCodexAppApprovalModal(msg);
        break;

      case 'ccweb_mcp_child_agent_update':
        applyCcwebMcpChildAgentUpdate(msg);
        break;

      case 'ccweb_prompt_user_update':
        applyCcwebPromptUserUpdate(msg);
        break;

      case 'ccweb_prompt_user_remove':
        applyCcwebPromptUserRemove(msg);
        break;

      case 'mcp_startup_status':
        rememberMcpStartupStatus(msg.sessionId, msg.mcpStatus || msg.status);
        showMcpStartupStatusToast(msg.mcpStatus || msg.status, msg.sessionId);
        break;

      case 'mode_changed':
        if (msg.mode && MODE_LABELS[msg.mode]) {
          currentMode = msg.mode;
          modeSelect.value = currentMode;
          localStorage.setItem(getAgentModeStorageKey(currentAgent), currentMode);
          if (currentSessionId) {
            updateCachedSession(currentSessionId, (snapshot) => { snapshot.mode = msg.mode; });
          }
        }
        break;

      case 'model_changed':
        if (msg.model) {
          currentModel = msg.model;
          if (currentSessionId) {
            updateCachedSession(currentSessionId, (snapshot) => { snapshot.model = msg.model; });
          }
        }
        break;

      case 'resume_generating':
        if (!isCurrentSessionEvent(msg)) break;
        clearPendingSessionResumeRequest(msg.sessionId || currentSessionId, msg.requestId);
        // Server has an active process for this session — resume streaming
        setCurrentSessionRunningState(true);
        if (!isGenerating || !document.getElementById('streaming-msg')) {
          startGenerating(msg.sessionId || currentSessionId, { follow: isNearBottom() });
        } else {
          updateGenerationControls();
          toolGroupCount = 0;
          hasGrouped = false;
          activeToolCalls.clear();
          activeTodoCallTargets.clear();
          const toolsDiv = document.querySelector('#streaming-msg .msg-tools');
          if (toolsDiv) toolsDiv.innerHTML = '';
        }
        pendingText = msg.text || '';
        flushRender();
        if (msg.toolCalls && msg.toolCalls.length > 0) {
          const mergedCollabTool = mergeCollabAgentTools(msg.toolCalls);
          const resumeToolCalls = [
            ...(mergedCollabTool ? [mergedCollabTool] : []),
            ...msg.toolCalls.filter((tc) => toolKind(tc) !== 'collab_agent_tool_call'),
          ];
          for (const tc of resumeToolCalls) {
            activeToolCalls.set(tc.id, {
              name: tc.name,
              input: tc.input,
              result: tc.result,
              kind: tc.kind || null,
              meta: tc.meta || null,
              done: tc.done,
            });
            appendToolCall(tc.id, tc.name, tc.input, tc.done, tc.kind || null, tc.meta || null);
            if (tc.result !== undefined && tc.result !== null) {
              updateToolCall(tc.id, tc.result, !!tc.done);
            }
          }
        }
        break;

      case 'resume_session_result':
        if (!isCurrentSessionEvent(msg)) break;
        clearPendingSessionResumeRequest(msg.sessionId || currentSessionId, msg.requestId);
        setCurrentSessionRunningState(!!msg.isRunning);
        if (!msg.isRunning && currentSessionId && msg.sessionId === currentSessionId) {
          updateGenerationControls();
        }
        break;

      case 'error':
        const errorRequestId = String(msg.requestId || '');
        const matchesActiveLoadError = !!(activeSessionLoad &&
          (!msg.sessionId || msg.sessionId === activeSessionLoad.sessionId) &&
          (!errorRequestId || errorRequestId === activeSessionLoad.requestId));
        if (!matchesActiveLoadError && !isCurrentSessionEvent(msg)) break;
        if (matchesActiveLoadError) {
          clearPendingSessionSwitchRequest(activeSessionLoad.sessionId, activeSessionLoad.requestId);
        }
        if (msg.code === 'new_session_cwd_missing' && pendingNewSessionRequest?.cwd) {
          const request = pendingNewSessionRequest;
          pendingNewSessionRequest = null;
          showSimpleConfirm({
            title: '目录不存在',
            message: `${msg.cwd || request.cwd}\n\n要先创建这个目录再进入新会话吗？`,
            confirmText: '创建目录',
            cancelText: '返回修改',
            onConfirm: () => {
              pendingNewSessionRequest = { ...request };
              send({
                type: 'new_session',
                cwd: request.cwd,
                agent: request.agent,
                mode: request.mode,
                model: request.model,
                title: request.title,
                branchSourceSessionId: request.branchSourceSessionId,
                branchMessageIndex: request.branchMessageIndex,
                createCwd: true,
                requestId: request.requestId,
              });
            },
            onCancel: () => {
              showNewSessionModal({
                agent: request.agent,
                cwd: request.rawCwd || request.cwd,
                mode: request.mode,
                model: request.model,
                title: request.title,
                branchSourceSessionId: request.branchSourceSessionId,
                branchMessageIndex: request.branchMessageIndex,
              });
            },
          });
          clearSessionLoading();
          if (!isGenerating && currentSessionId) {
            setCurrentSessionRunningState(!!getSessionMeta(currentSessionId)?.isRunning);
          }
          break;
        }
        if (pendingNewSessionRequest) pendingNewSessionRequest = null;
        applyPendingSlashDraftResponse(msg);
        appendError(msg.message, {
          transient: msg.transient,
          autoDismissMs: msg.autoDismissMs,
        });
        clearSessionLoading();
        if (!isGenerating && currentSessionId) {
          setCurrentSessionRunningState(!!getSessionMeta(currentSessionId)?.isRunning);
        }
        if (isGenerating) finishGenerating();
        break;

      case 'notify_config':
        if (typeof _onNotifyConfig === 'function') _onNotifyConfig(msg.config);
        // Update summary in parent settings panel if visible
        if (msg.config) {
          const provider = msg.config.provider || 'off';
          const providerLabel = PROVIDER_OPTIONS.find(o => o.value === provider)?.label || '关闭';
          const summaryOn = msg.config.summary?.enabled ? '摘要已启用' : '摘要关闭';
          const meta = provider === 'off' ? '未启用' : `${providerLabel} · ${summaryOn}`;
          document.querySelectorAll('[data-notify-summary]').forEach(el => { el.textContent = meta; });
        }
        break;

      case 'notify_test_result':
        if (typeof _onNotifyTestResult === 'function') _onNotifyTestResult(msg);
        break;

      case 'model_config':
        if (typeof _onModelConfig === 'function') _onModelConfig(msg.config);
        break;

      case 'codex_config':
        codexConfigCache = msg.config || null;
        if (typeof _onCodexConfig === 'function') _onCodexConfig(msg.config);
        break;

      case 'fetch_models_result':
        if (typeof _onFetchModelsResult === 'function') _onFetchModelsResult(msg);
        break;

      case 'background_done':
        // A background task completed (browser was disconnected or viewing another session)
        showToast(`「${msg.title}」任务完成`, msg.sessionId);
        showBrowserNotification(msg.title);
        if (msg.sessionId === currentSessionId) {
          // 当前用户如果正在向上翻历史，不要自动整屏重绘并把滚动条拽回底部。
          if (isNearBottom()) {
            openSession(msg.sessionId, { forceSync: true, blocking: false });
          } else {
            send({ type: 'list_sessions' });
          }
        } else {
          send({ type: 'list_sessions' });
        }
        break;

      case 'password_changed':
        handlePasswordChanged(msg);
        break;

      case 'native_sessions':
        if (typeof _onNativeSessions === 'function') _onNativeSessions(msg.groups || []);
        break;

      case 'codex_sessions':
        if (typeof _onCodexSessions === 'function') _onCodexSessions(msg.sessions || []);
        break;

      case 'cwd_suggestions':
        if (typeof _onCwdSuggestions === 'function') _onCwdSuggestions(msg);
        break;

      case 'composer_suggestions':
        handleComposerSuggestions(msg);
        break;

      case 'update_info':
        if (typeof window._ccOnUpdateInfo === 'function') window._ccOnUpdateInfo(msg);
        break;
    }
  }

  // --- Generating State ---
  function startGenerating(sessionId = currentSessionId, options = {}) {
    const targetSessionId = sessionId || currentSessionId || null;
    if (targetSessionId && currentSessionId && targetSessionId !== currentSessionId) return false;
    const shouldFollow = options.follow !== false;
    isGenerating = true;
    generatingSessionId = targetSessionId;
    setCurrentSessionRunningState(true);
    pendingText = '';
    window.pendingContentBlocks = [];
    activeToolCalls.clear();
    activeTodoCallTargets.clear();
    toolGroupCount = 0;
    hasGrouped = false;
    updateNoteModeUI();
    renderPendingNotes({ scroll: false });
    // 不禁用输入框，允许用户继续输入（但无法发送）

    const welcome = messagesDiv.querySelector('.welcome-msg');
    if (welcome) welcome.remove();

    const msgEl = createMsgElement('assistant', '');
    msgEl.id = 'streaming-msg';
    if (targetSessionId) msgEl.dataset.sessionId = targetSessionId;
    // 流式消息 bubble 拆为 .msg-text 和 .msg-tools 两个子容器
    const bubble = msgEl.querySelector('.msg-bubble');
    bubble.innerHTML = '';
    const textDiv = document.createElement('div');
    textDiv.className = 'msg-text';
    textDiv.innerHTML = '<div class="typing-indicator"><span></span><span></span><span></span></div>';
    const toolsDiv = document.createElement('div');
    toolsDiv.className = 'msg-tools';
    bubble.appendChild(textDiv);
    bubble.appendChild(toolsDiv);
    syncAssistantLastSectionButton(msgEl);
    messagesDiv.appendChild(msgEl);
    if (shouldFollow) {
      scrollToBottom();
    } else {
      updateScrollbar();
    }
    return true;
  }

  function finishGenerating(sessionId) {
    if (sessionId && currentSessionId && sessionId !== currentSessionId) return;
    const hasPersistedAssistantMessage = !!(
      pendingText
      || (Array.isArray(window.pendingContentBlocks) && window.pendingContentBlocks.length > 0)
    );
    isGenerating = false;
    generatingSessionId = null;
    updateNoteModeUI();
    setCurrentSessionRunningState(false);
    msgInput.focus();

    if (pendingText || (window.pendingContentBlocks && window.pendingContentBlocks.length > 0)) {
      flushRender();
    }

    window.pendingContentBlocks = [];

    const typing = document.querySelector('.typing-indicator');
    if (typing) typing.remove();

    const streamEl = document.getElementById('streaming-msg');
    if (streamEl) {
      // 若本轮出现过父目录，把末尾散落的 .tool-call 也一并收入同一父节点
      if (hasGrouped) {
        const toolsDiv = streamEl.querySelector('.msg-tools');
        if (toolsDiv) {
          const loose = Array.from(toolsDiv.children).filter(isGroupableToolCall);
          if (loose.length > 0) {
            let group = toolsDiv.querySelector(':scope > .tool-group');
            if (!group) {
              group = document.createElement('details');
              group.className = 'tool-group';
              const gs = document.createElement('summary');
              gs.className = 'tool-group-summary';
              group.appendChild(gs);
              const inner = document.createElement('div');
              inner.className = 'tool-group-inner';
              group.appendChild(inner);
              toolsDiv.insertBefore(group, toolsDiv.firstChild);
            }
            const inner = group.querySelector('.tool-group-inner');
            loose.forEach(c => inner.appendChild(c));
            _refreshGroupSummary(group);
          }
        }
      }
      streamEl.removeAttribute('id');
      if (hasPersistedAssistantMessage && currentSessionId) {
        const messageIndex = currentSessionMessageCount;
        currentSessionMessageCount += 1;
        markSessionMessageElement(streamEl, messageIndex);
      }
      syncAssistantLastSectionButton(streamEl);
    }

    if (sessionId) {
      migratePendingNotesToSession(sessionId, currentAgent);
      migrateQueuedMessagesToSession(sessionId, currentAgent);
    }
    pendingText = '';
    activeToolCalls.clear();
    activeTodoCallTargets.clear();
    toolGroupCount = 0;
    hasGrouped = false;
    renderPendingNotes();
    scheduleQueuedMessageDrain();
  }

  // --- Rendering ---
  function scheduleRender() {
    if (renderTimer) return;
    renderTimer = setTimeout(() => {
      renderTimer = null;
      flushRender();
    }, RENDER_DEBOUNCE);
  }

  function flushRender() {
    const streamEl = document.getElementById('streaming-msg');
    if (!streamEl) return;
    const bubble = streamEl.querySelector('.msg-bubble');
    if (!bubble) return;
    const shouldFollow = isNearBottom();

    if (window.pendingContentBlocks && window.pendingContentBlocks.length > 0) {
      bubble.innerHTML = '';
      renderAssistantContent(bubble, window.pendingContentBlocks);
    } else if (pendingText) {
      let textDiv = bubble.querySelector('.msg-text');
      if (!textDiv) { textDiv = bubble; }
      setRenderedMarkdown(textDiv, pendingText);
    }
    syncAssistantLastSectionButton(streamEl);
    followOutputIfNeeded(shouldFollow);
  }

  function renderMarkdown(text) {
    if (!text) return '<div class="typing-indicator"><span></span><span></span><span></span></div>';
    try { return marked.parse(text); }
    catch { return escapeHtml(text); }
  }

  function codexAppSteerStatusLabel(status) {
    if (status === 'inserted') return '已插入';
    if (status === 'failed') return '插入失败';
    return '引导中...';
  }

  function setCodexAppSteerStatusElement(element, status, message) {
    if (!element) return false;
    const normalized = ['pending', 'inserted', 'failed'].includes(status) ? status : 'pending';
    element.classList.add('codex-steer-message');
    element.classList.toggle('codex-steer-pending', normalized === 'pending');
    element.classList.toggle('codex-steer-inserted', normalized === 'inserted');
    element.classList.toggle('codex-steer-failed', normalized === 'failed');
    const bubble = element.querySelector('.msg-bubble');
    if (!bubble) return false;
    let statusEl = bubble.querySelector('.codex-steer-status');
    if (!statusEl) {
      statusEl = document.createElement('div');
      statusEl.className = 'codex-steer-status';
      bubble.appendChild(statusEl);
    }
    statusEl.dataset.status = normalized;
    statusEl.textContent = message || codexAppSteerStatusLabel(normalized);
    return true;
  }

  function updateCodexAppSteerMessage(clientMessageId, status, message) {
    const id = String(clientMessageId || '').trim();
    if (!id) return false;
    const indexed = userMessageIndex.get(id);
    const element = indexed?.element || messagesDiv.querySelector(`[data-message-id="${cssEscape(id)}"]`);
    return setCodexAppSteerStatusElement(element, status, message);
  }

  function scheduleTransientMessageRemoval(element, timeoutMs) {
    const ttl = Number(timeoutMs);
    if (!element || !Number.isFinite(ttl) || ttl <= 0) return;
    window.setTimeout(() => {
      if (!element || !element.isConnected) return;
      element.classList.add('is-dismissing');
      window.setTimeout(() => {
        if (!element || !element.isConnected) return;
        element.remove();
        updateScrollbar();
      }, 220);
    }, ttl);
  }

  function normalizeMentionList(value) {
    return Array.isArray(value) ? value.filter((item) => item && typeof item === 'object') : [];
  }

  function escapeHtmlAttr(value) {
    return String(value || '')
      .replace(/&/g, '&amp;')
      .replace(/"/g, '&quot;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;');
  }

  function shortPreviewText(text, maxLength = 140) {
    const normalized = String(text || '').trim().replace(/\s+/g, ' ');
    if (!normalized) return '';
    return normalized.length > maxLength ? `${normalized.slice(0, Math.max(0, maxLength - 3))}...` : normalized;
  }

  function mentionDependencyStateLabel(state) {
    return state === 'configured' ? '已配置' : state === 'declared' ? '未配置' : '';
  }

  function mentionDependencyLabel(dep) {
    const name = dep?.value || dep?.name || dep?.server || '';
    const state = mentionDependencyStateLabel(dep?.state);
    return state ? `${name} · ${state}` : name;
  }

  function buildMentionTooltip(mention) {
    const lines = [];
    const title = mention.title || mention.name || mention.label || '';
    const description = shortPreviewText(mention.description || '');
    if (title) lines.push(title);
    if (description) lines.push(description);
    if (mention.defaultPromptPreview) lines.push(`默认提示: ${shortPreviewText(mention.defaultPromptPreview, 180)}`);
    const dependencies = Array.isArray(mention.dependencies) ? mention.dependencies : [];
    for (const dep of dependencies.slice(0, 4)) {
      const label = mentionDependencyLabel(dep);
      if (label) lines.push(`MCP: ${label}`);
    }
    return lines.join('\n');
  }

  function createMentionChip(mention) {
    const chip = document.createElement('div');
    const kind = String(mention.kind || '').trim() || 'mention';
    chip.className = `msg-mention-chip kind-${kind}`;
    if (mention.brandColor) chip.style.setProperty('--mention-accent', mention.brandColor);
    const tooltip = buildMentionTooltip(mention);
    if (tooltip) chip.title = tooltip;

    if (kind === 'skill') {
      const badge = document.createElement('span');
      badge.className = 'msg-mention-badge';
      if (mention.iconSmall && /^https?:\/\//i.test(String(mention.iconSmall))) {
        const img = document.createElement('img');
        img.src = mention.iconSmall;
        img.alt = mention.title || mention.name || 'skill';
        img.loading = 'lazy';
        badge.appendChild(img);
      } else {
        badge.textContent = 'Skill';
      }
      chip.appendChild(badge);
    }

    const body = document.createElement('div');
    body.className = 'msg-mention-body';

    const title = document.createElement('div');
    title.className = 'msg-mention-title';
    if (kind === 'skill') {
      title.textContent = mention.title || mention.name || mention.label || '';
    } else if (kind === 'prompt') {
      title.textContent = mention.title || mention.label || mention.name || '';
    } else {
      title.textContent = mention.label || mention.name || '';
    }
    body.appendChild(title);

    const descriptionText = mention.description || (kind === 'prompt' ? 'Prompt 模板' : '');
    if (descriptionText) {
      const desc = document.createElement('div');
      desc.className = 'msg-mention-desc';
      desc.textContent = shortPreviewText(descriptionText, kind === 'skill' ? 92 : 72);
      body.appendChild(desc);
    }

    const dependencies = Array.isArray(mention.dependencies) ? mention.dependencies : [];
    if (dependencies.length > 0) {
      const meta = document.createElement('div');
      meta.className = 'msg-mention-meta';
      dependencies.slice(0, 2).forEach((dep) => {
        const pill = document.createElement('span');
        pill.className = `msg-mention-pill state-${dep.state || 'declared'}`;
        pill.textContent = mentionDependencyLabel(dep);
        meta.appendChild(pill);
      });
      body.appendChild(meta);
    }

    chip.appendChild(body);
    return chip;
  }

  function renderComposerMentionsStrip(meta) {
    const mentions = normalizeMentionList(meta?.composerMentions);
    if (mentions.length === 0) return null;
    const wrap = document.createElement('div');
    wrap.className = 'msg-mentions';
    mentions.forEach((mention) => wrap.appendChild(createMentionChip(mention)));
    return wrap;
  }

  function createMsgElement(role, content, attachments = [], meta = {}) {
    const div = document.createElement('div');
    const isCrossConversation = !!meta.crossConversation;
    const isCrossConversationReply = isCrossConversation && !!(meta.crossConversation.reply || meta.crossConversation.replyToRequestId);
    const canCollapseCrossConversationReply = role === 'assistant' && isCrossConversationReply;
    const resolvedMessageId = meta?.messageId || meta?.id || createLocalId('user');
    div.className = `msg ${role}${role === 'assistant' ? ' agent-' + currentAgent : ''}${isCrossConversation ? ' cross-conversation' : ''}${isCrossConversationReply ? ' cross-conversation-reply' : ''}`;
    if (role === 'user') {
      div.id = `hapi-message-${resolvedMessageId}`;
      div.dataset.messageId = resolvedMessageId;
    }

    if (role === 'system') {
      const tone = String(meta.tone || 'neutral').trim() || 'neutral';
      const bubble = document.createElement('div');
      bubble.className = 'msg-bubble';
      bubble.dataset.tone = tone;
      const text = document.createElement('span');
      text.className = 'system-message-text';
      text.textContent = content;
      bubble.appendChild(text);

      const transient = !!meta.transient;
      if (transient) {
        div.classList.add('transient');
      }

      const closeBtn = document.createElement('button');
      closeBtn.type = 'button';
      closeBtn.className = 'system-message-close';
      closeBtn.title = '关闭提示';
      closeBtn.setAttribute('aria-label', '关闭提示');
      closeBtn.textContent = '×';
      closeBtn.addEventListener('click', (event) => {
        event.stopPropagation();
        div.remove();
        updateScrollbar();
      });
      bubble.appendChild(closeBtn);
      div.appendChild(bubble);
      if (transient) {
        scheduleTransientMessageRemoval(div, meta.autoDismissMs || 6000);
      }
      return div;
    }

    const avatar = document.createElement('div');
    avatar.className = 'msg-avatar';
    if (isCrossConversation) {
      avatar.textContent = isCrossConversationReply ? '↩' : '↗';
    } else if (role === 'user') {
      avatar.textContent = 'U';
    } else if (isCodexLikeAgent(currentAgent)) {
      avatar.innerHTML = `<img src="/codex.png" width="24" height="24" style="display:block;" alt="${isCodexAppAgent(currentAgent) ? 'Codex App' : 'Codex'}">`;
    } else {
      avatar.innerHTML = `<img src="/claude.png" width="24" height="24" style="display:block;" alt="Claude">`;
    }

    const bubble = document.createElement('div');
    bubble.className = 'msg-bubble';
    let crossConversationReplyCollapseKey = '';
    let crossConversationReplyToggle = null;
    let crossConversationReplyBody = null;

    const applyCrossConversationReplyCollapseState = (collapsed) => {
      if (!crossConversationReplyToggle || !crossConversationReplyBody) return;
      div.classList.toggle('cross-conversation-collapsed', collapsed);
      bubble.dataset.collapsed = collapsed ? 'true' : 'false';
      crossConversationReplyBody.hidden = collapsed;
      crossConversationReplyToggle.textContent = collapsed ? '展开' : '收起';
      crossConversationReplyToggle.title = collapsed ? '展开返回消息' : '收起返回消息';
      crossConversationReplyToggle.setAttribute('aria-label', collapsed ? '展开返回消息' : '收起返回消息');
      crossConversationReplyToggle.setAttribute('aria-expanded', String(!collapsed));
      updateScrollbar();
    };

    if (canCollapseCrossConversationReply) {
      crossConversationReplyCollapseKey = getCrossConversationReplyCollapseKey(meta);
      if (crossConversationReplyCollapseKey) {
        div.dataset.crossConversationReplyKey = crossConversationReplyCollapseKey;
      }
    }

    if (isCrossConversation) {
      const source = meta.crossConversation || {};
      const sourceTitle = source.sourceTitle || '未命名对话';
      const sourceId = source.sourceSessionId || '';
      const sourceMeta = document.createElement('div');
      sourceMeta.className = 'cross-conversation-meta';

      const label = document.createElement('span');
      label.className = 'cross-conversation-label';
      label.textContent = isCrossConversationReply
        ? `来自「${sourceTitle}」的回复`
        : `来自「${sourceTitle}」的对话`;
      sourceMeta.appendChild(label);

      if (sourceId) {
        const copyBtn = document.createElement('button');
        copyBtn.type = 'button';
        copyBtn.className = 'cross-conversation-id-btn';
        copyBtn.textContent = `ID ${shortSessionId(sourceId)}`;
        copyBtn.title = `复制来源会话 ID\n${sourceId}`;
        copyBtn.addEventListener('click', (event) => {
          event.stopPropagation();
          copyTextToClipboard(sourceId, '来源会话 ID 已复制');
        });
        sourceMeta.appendChild(copyBtn);
      }

      if (canCollapseCrossConversationReply) {
        const replyTimeText = formatCrossConversationReplyTime(meta.timestamp || source.processedAt || source.sentAt);
        if (replyTimeText) {
          const time = document.createElement('span');
          time.className = 'cross-conversation-time';
          time.textContent = replyTimeText;
          sourceMeta.appendChild(time);
        }

        crossConversationReplyToggle = document.createElement('button');
        crossConversationReplyToggle.type = 'button';
        crossConversationReplyToggle.className = 'cross-conversation-collapse-btn';
        crossConversationReplyToggle.addEventListener('click', (event) => {
          event.stopPropagation();
          const collapsed = !div.classList.contains('cross-conversation-collapsed');
          setCrossConversationReplyCollapsed(crossConversationReplyCollapseKey, collapsed);
          applyCrossConversationReplyCollapseState(collapsed);
        });
        sourceMeta.appendChild(crossConversationReplyToggle);
      }

      bubble.appendChild(sourceMeta);
    }

    if (role === 'user') {
      if (content) {
        const textNode = document.createElement('div');
        textNode.className = 'msg-text';
        textNode.style.whiteSpace = 'pre-wrap';
        textNode.textContent = content;
        bubble.appendChild(textNode);

        const copyBtn = document.createElement('button');
        copyBtn.type = 'button';
        copyBtn.className = 'msg-copy-btn';
        copyBtn.title = '复制用户消息';
        copyBtn.setAttribute('aria-label', '复制用户消息');
        copyBtn.innerHTML = `
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
            <rect x="9" y="9" width="10" height="10" rx="2"></rect>
            <path d="M5 15H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v1"></path>
          </svg>
        `;
        copyBtn.addEventListener('click', (event) => {
          event.stopPropagation();
          copyTextToClipboard(content, '用户消息已复制');
        });
        bubble.appendChild(copyBtn);
      }
      if (attachments.length > 0) {
        bubble.insertAdjacentHTML('beforeend', renderAttachmentPreviews(attachments));
      }
      const mentionsStrip = renderComposerMentionsStrip(meta);
      if (mentionsStrip) bubble.appendChild(mentionsStrip);
    } else {
      const assistantContentTarget = canCollapseCrossConversationReply ? document.createElement('div') : bubble;
      if (canCollapseCrossConversationReply) {
        assistantContentTarget.className = 'cross-conversation-reply-body';
        crossConversationReplyBody = assistantContentTarget;
      }
      renderAssistantContent(assistantContentTarget, content);
      if (attachments.length > 0) {
        assistantContentTarget.insertAdjacentHTML('beforeend', renderAttachmentPreviews(attachments));
      }
      if (canCollapseCrossConversationReply) {
        bubble.appendChild(assistantContentTarget);
      }
    }

    hydrateAttachmentPreviews(bubble, attachments);
    div.appendChild(avatar);
    div.appendChild(bubble);
    if (role === 'assistant') {
      syncAssistantLastSectionButton(div);
    }
    if (canCollapseCrossConversationReply) {
      applyCrossConversationReplyCollapseState(isCrossConversationReplyCollapsed(crossConversationReplyCollapseKey));
    }
    if (role === 'user' && meta.codexAppSteerStatus) {
      setCodexAppSteerStatusElement(div, meta.codexAppSteerStatus, meta.codexAppSteerMessage);
    }
    if (role === 'user') {
      registerUserMessage(resolvedMessageId, div, content);
    }
    return div;
  }

  function renderAssistantContent(bubble, content) {
    if (!content) return;

    if (typeof content === 'string') {
      // 检测并提取 JSON 格式的 todo_list（可能在代码块中）
      const jsonMatch = content.match(/```json\s*(\{[\s\S]*?"type"\s*:\s*"todo_list"[\s\S]*?\})\s*```/);
      if (jsonMatch) {
        try {
          const parsed = JSON.parse(jsonMatch[1]);
          if (parsed.type === 'todo_list') {
            const before = content.substring(0, jsonMatch.index);
            const after = content.substring(jsonMatch.index + jsonMatch[0].length);
            if (before.trim()) {
              const textDiv = document.createElement('div');
              setRenderedMarkdown(textDiv, before, false);
              bubble.appendChild(textDiv);
              hydrateRenderedMarkdown(textDiv);
            }
            bubble.appendChild(createTodoListElement(parsed));
            if (after.trim()) {
              const textDiv = document.createElement('div');
              setRenderedMarkdown(textDiv, after, false);
              bubble.appendChild(textDiv);
              hydrateRenderedMarkdown(textDiv);
            }
            return;
          }
        } catch (e) {}
      }

      // 尝试直接解析 JSON
      const trimmed = content.trim();
      if (trimmed.startsWith('{') && trimmed.includes('"type"') && trimmed.includes('"todo_list"')) {
        try {
          const parsed = JSON.parse(trimmed);
          if (parsed.type === 'todo_list') {
            bubble.appendChild(createTodoListElement(parsed));
            return;
          }
        } catch (e) {}
      }
      setRenderedMarkdown(bubble, content);
      return;
    }

    if (Array.isArray(content)) {
      content.forEach(block => {
        if (block.type === 'text') {
          const textDiv = document.createElement('div');
          setRenderedMarkdown(textDiv, block.text || '', false);
          bubble.appendChild(textDiv);
          hydrateRenderedMarkdown(textDiv);
        } else if (block.type === 'todo_list') {
          bubble.appendChild(createTodoListElement(block));
        }
      });
      return;
    }

    setRenderedMarkdown(bubble, String(content));
  }

  function createTodoListElement(block) {
    const container = document.createElement('div');
    container.className = 'todo-list-container';
    container.dataset.todoId = block.id || '';

    const list = document.createElement('ul');
    list.className = 'todo-list';

    if (Array.isArray(block.items)) {
      block.items.forEach((item, index) => {
        const li = document.createElement('li');
        li.className = 'todo-item' + (item.completed ? ' completed' : '');

        const checkbox = document.createElement('input');
        checkbox.type = 'checkbox';
        checkbox.className = 'todo-checkbox';
        checkbox.checked = item.completed || false;
        checkbox.dataset.index = index;

        const label = document.createElement('span');
        label.className = 'todo-text';
        label.textContent = item.text || '';

        li.appendChild(checkbox);
        li.appendChild(label);
        list.appendChild(li);
      });
    }

    container.appendChild(list);
    return container;
  }

  let renderEpoch = 0;

  function toolKind(tool) {
    return tool?.kind || tool?.meta?.kind || '';
  }

  function normalizeDisplayPath(filePath) {
    const rawPath = typeof filePath === 'string' ? filePath.trim() : '';
    if (!rawPath) return '';
    const normalizedPath = rawPath.replace(/\\/g, '/');
    const normalizedCwd = typeof currentCwd === 'string' ? currentCwd.trim().replace(/\\/g, '/') : '';
    if (normalizedCwd && normalizedPath.startsWith(`${normalizedCwd}/`)) {
      return normalizedPath.slice(normalizedCwd.length + 1);
    }
    return normalizedPath;
  }

  function getFileChangeDisplay(tool) {
    const changes = Array.isArray(tool?.input?.changes) ? tool.input.changes : [];
    const primaryChange = changes[0] || null;
    const rawPath = tool?.meta?.subtitle || primaryChange?.path || tool?.input?.path || tool?.input?.file_path || '';
    const filePath = normalizeDisplayPath(rawPath);
    const rawKind = primaryChange?.kind || tool?.input?.kind || '';
    let action = '修改';
    if (rawKind === 'create' || rawKind === 'new') {
      action = '创建';
    } else if (rawKind === 'delete' || rawKind === 'remove') {
      action = '删除';
    } else if (rawKind === 'update' || rawKind === 'edit') {
      action = '更新';
    } else if (tool?.input?.new_string && tool?.input?.old_string) {
      action = '更新';
    }
    return { action, filePath, changeCount: changes.length };
  }

  function toolTitle(tool) {
    if (toolKind(tool) === 'file_change') {
      const { action, filePath, changeCount } = getFileChangeDisplay(tool);
      if (filePath && changeCount > 1) return `${action} ${filePath} 等 ${changeCount} 个文件`;
      if (filePath) return `${action} ${filePath}`;
      if (changeCount > 1) return `修改 ${changeCount} 个文件`;
      return tool?.meta?.title || 'File Change';
    }
    if (tool?.meta?.title) return tool.meta.title;
    return tool?.name || 'Tool';
  }

  function toolSubtitle(tool) {
    if (toolKind(tool) === 'file_change') {
      return '';
    }
    if (tool?.meta?.subtitle) return tool.meta.subtitle;
    if (toolKind(tool) === 'command_execution') {
      return tool?.input?.command || '';
    }
    return '';
  }

  function stringifyToolValue(value) {
    if (typeof value === 'string') return value;
    if (value === null || value === undefined) return '';
    try {
      return JSON.stringify(value, null, 2);
    } catch {
      return String(value);
    }
  }

  function reasoningPartText(parts) {
    if (!Array.isArray(parts)) return '';
    return parts.map((part) => {
      if (typeof part === 'string') return part;
      if (typeof part?.text === 'string') return part.text;
      return '';
    }).filter(Boolean).join('\n');
  }

  function isEmptyReasoningTool(tool) {
    if (toolKind(tool) !== 'reasoning') return false;
    const resultText = stringifyToolValue(tool?.result).trim();
    const input = tool?.input || {};
    const inputText = [
      reasoningPartText(input.content),
      reasoningPartText(input.summary),
    ].filter(Boolean).join('\n').trim();
    return !resultText && !inputText;
  }

  function toolStateLabel(tool, done) {
    if (!done) return 'Running';
    if (toolKind(tool) === 'command_execution' && typeof tool?.meta?.exitCode === 'number') {
      return `Exit ${tool.meta.exitCode}`;
    }
    return 'Done';
  }

  function toolStateClass(tool, done) {
    if (!done) return 'running';
    if (toolKind(tool) === 'command_execution' && typeof tool?.meta?.exitCode === 'number' && tool.meta.exitCode !== 0) {
      return 'error';
    }
    return 'done';
  }

  function applyToolSummary(summary, tool, done) {
    summary.innerHTML = '';
    const icon = document.createElement('span');
    icon.className = `tool-call-icon ${done ? 'done' : 'running'}`;

    const main = document.createElement('span');
    main.className = 'tool-call-summary-main';
    const label = document.createElement('span');
    label.className = 'tool-call-label';
    label.textContent = toolTitle(tool);
    main.appendChild(label);

    const subtitleText = toolSubtitle(tool);
    if (subtitleText) {
      const subtitle = document.createElement('span');
      subtitle.className = 'tool-call-subtitle';
      subtitle.textContent = subtitleText;
      main.appendChild(subtitle);
    }

    const state = document.createElement('span');
    state.className = `tool-call-state ${toolStateClass(tool, done)}`;
    state.textContent = toolStateLabel(tool, done);

    summary.appendChild(icon);
    summary.appendChild(main);
    summary.appendChild(state);
  }

  function buildStructuredToolSection(labelText, bodyText) {
    const section = document.createElement('div');
    section.className = 'tool-call-section';
    const label = document.createElement('div');
    label.className = 'tool-call-section-label';
    label.textContent = labelText;
    const pre = document.createElement('pre');
    pre.className = 'tool-call-code';
    pre.textContent = bodyText;
    section.appendChild(label);
    section.appendChild(pre);
    return section;
  }

  function parseMaybeJsonObject(value) {
    if (value && typeof value === 'object') return value;
    if (typeof value !== 'string') return null;
    const trimmed = value.trim();
    if (!trimmed || (!trimmed.startsWith('{') && !trimmed.startsWith('['))) return null;
    try {
      const parsed = JSON.parse(trimmed);
      return parsed && typeof parsed === 'object' ? parsed : null;
    } catch {
      return null;
    }
  }

  function summarizePrompt(prompt) {
    const text = typeof prompt === 'string' ? prompt.trim().replace(/\s+/g, ' ') : '';
    if (!text) return '';
    return text.length > 140 ? `${text.slice(0, 140)}…` : text;
  }

  function normalizeCollabAgentAction(value) {
    const raw = String(value || '').trim();
    if (!raw) return '';
    const name = raw.split(/[./]/).filter(Boolean).pop() || raw;
    const normalized = name.toLowerCase().replace(/[\s_-]/g, '');
    if (normalized === 'spawn' || normalized === 'spawnagent') return 'spawn_agent';
    if (normalized === 'wait' || normalized === 'waitagent') return 'wait_agent';
    if (normalized === 'close' || normalized === 'closeagent') return 'close_agent';
    if (normalized === 'sendinput' || normalized === 'sendmessage') return 'send_input';
    if (normalized === 'resume' || normalized === 'resumeagent') return 'resume_agent';
    return normalized;
  }

  function getCollabAgentAction(tool, data = null) {
    const value = data?.tool || tool?.name || tool?.meta?.title || '';
    return normalizeCollabAgentAction(value);
  }

  function normalizeCollabAgentData(tool) {
    const inputData = effectiveObject(tool?.input);
    const resultData = effectiveObject(tool?.result);
    const merged = {
      ...inputData,
      ...resultData,
      agentsStates: resultData.agentsStates || resultData.agents_states || inputData.agentsStates || inputData.agents_states || {},
      receiverThreadIds: resultData.receiverThreadIds || resultData.receiver_thread_ids || resultData.targets || inputData.receiverThreadIds || inputData.receiver_thread_ids || inputData.targets || [],
      prompt: inputData.prompt || resultData.prompt || '',
      tool: inputData.tool || resultData.tool || tool?.name || '',
      status: resultData.status || inputData.status || tool?.meta?.status || null,
    };
    return merged;
  }

  function effectiveObject(value) {
    const parsed = parseMaybeJsonObject(value);
    if (parsed && !Array.isArray(parsed)) return parsed;
    if (value && typeof value === 'object' && !Array.isArray(value)) return value;
    return {};
  }

  function collabAgentStateEntries(data) {
    const states = data?.agentsStates;
    if (!states || typeof states !== 'object') return [];
    return Object.entries(states).map(([id, value], index) => {
      const state = value && typeof value === 'object' ? value : { status: value };
      const label = String(state.label || state.title || state.nickname || state.name || `子代理 ${index + 1}`);
      const role = String(state.role || state.agent || state.agentType || '').trim();
      let status = String(state.status || state.state || 'pending').trim() || 'pending';
      if (state.closedAt && collabStateTone(status) !== 'closed') status = 'closed';
      const detail = String(state.candidateResult || state.finalMessage || state.summary || state.message || state.lastMessage || state.step || state.description || '').trim();
      return { id, label, role, status, detail };
    });
  }

  function getCollabAgentIdsFromTool(tool) {
    const data = normalizeCollabAgentData(tool);
    const ids = new Set();
    for (const entry of collabAgentStateEntries(data)) {
      if (entry.id) ids.add(entry.id);
    }
    if (Array.isArray(data.receiverThreadIds)) {
      data.receiverThreadIds.forEach((id) => {
        if (id) ids.add(String(id));
      });
    }
    const directIds = [
      data.threadId,
      data.thread_id,
      data.agentId,
      data.agent_id,
      data.childThreadId,
      data.child_thread_id,
      data.childAgentId,
      data.child_agent_id,
      data.targetThreadId,
      data.target_thread_id,
      data.target,
    ];
    directIds.forEach((id) => {
      if (id) ids.add(String(id));
    });
    const directArrays = [
      data.threadIds,
      data.thread_ids,
      data.childThreadIds,
      data.child_thread_ids,
      data.agentIds,
      data.agent_ids,
      data.targets,
    ];
    directArrays.forEach((value) => {
      if (!Array.isArray(value)) return;
      value.forEach((id) => {
        if (id) ids.add(String(id));
      });
    });
    return Array.from(ids);
  }

  function getClosedCollabAgentIdsFromTool(tool) {
    if (toolKind(tool) !== 'collab_agent_tool_call') return [];
    const data = normalizeCollabAgentData(tool);
    const ids = new Set();
    const action = getCollabAgentAction(tool, data);
    const allIds = getCollabAgentIdsFromTool(tool);
    if (action === 'close_agent' || collabStateTone(data.status) === 'closed') {
      allIds.forEach((id) => ids.add(id));
    }
    collabAgentStateEntries(data).forEach((entry) => {
      if (entry.id && collabStateTone(entry.status) === 'closed') ids.add(entry.id);
    });
    return Array.from(ids);
  }

  function collectClosedCollabAgentIds(messages) {
    const ids = new Set();
    (Array.isArray(messages) ? messages : []).forEach((message) => {
      (Array.isArray(message?.toolCalls) ? message.toolCalls : []).forEach((tool) => {
        getClosedCollabAgentIdsFromTool(tool).forEach((id) => ids.add(id));
      });
    });
    return ids;
  }

  function rememberClosedCollabAgentIdsFromTool(tool) {
    getClosedCollabAgentIdsFromTool(tool).forEach((id) => closedCollabAgentIds.add(id));
  }

  function isGenericCollabAgentLabel(label, id) {
    const value = String(label || '').trim();
    if (!value) return true;
    if (/^子代理\s*\d+$/i.test(value)) return true;
    return !!id && value === String(id);
  }

  function mergeCollabAgentTools(tools, options = {}) {
    const list = Array.isArray(tools) ? tools.filter((tool) => toolKind(tool) === 'collab_agent_tool_call') : [];
    if (list.length === 0) return null;
    const states = {};
    const receiverThreadIds = [];
    const knownClosedIds = options.closedAgentIds instanceof Set ? options.closedAgentIds : closedCollabAgentIds;
    const localClosedIds = new Set(knownClosedIds || []);
    let toolName = '子代';
    let prompt = '';
    let status = '';
    let done = false;

    list.forEach((tool, toolIndex) => {
      const data = normalizeCollabAgentData(tool);
      const action = getCollabAgentAction(tool, data);
      const isCloseAction = action === 'close_agent';
      const displayAction = String(data.tool || tool.name || '').trim();
      if (displayAction && !['wait_agent', 'close_agent'].includes(action)) toolName = displayAction;
      if (!prompt && data.prompt) prompt = data.prompt;
      if (isCloseAction) {
        getCollabAgentIdsFromTool(tool).forEach((id) => localClosedIds.add(id));
      }
      const dataStatus = isCloseAction ? 'closed' : data.status;
      if (dataStatus) status = dataStatus;
      done = done || !!tool.done;

      collabAgentStateEntries(data).forEach((entry) => {
        if (!entry.id) return;
        states[entry.id] = {
          ...(states[entry.id] || {}),
          ...entry,
          status: isCloseAction || localClosedIds.has(entry.id) ? 'closed' : entry.status,
        };
        if (collabStateTone(states[entry.id].status) === 'closed') localClosedIds.add(entry.id);
      });

      getCollabAgentIdsFromTool(tool).forEach((id) => {
        if (!receiverThreadIds.includes(id)) receiverThreadIds.push(id);
        states[id] = {
          ...(states[id] || {}),
          label: states[id]?.label || `子代理 ${receiverThreadIds.length}`,
          status: isCloseAction || localClosedIds.has(id)
            ? 'closed'
            : (data.status || states[id]?.status || (tool.done ? 'completed' : 'running')),
        };
      });

      if (receiverThreadIds.length === 0 && list.length === 1) {
        const fallbackId = tool.id || `tool-${toolIndex + 1}`;
        receiverThreadIds.push(fallbackId);
        states[fallbackId] = {
          label: '子代理',
          status: isCloseAction ? 'closed' : (data.status || (tool.done ? 'completed' : 'running')),
        };
      }
    });

    receiverThreadIds.forEach((id, index) => {
      states[id] = {
        ...(states[id] || {}),
        label: states[id]?.label || `子代理 ${index + 1}`,
        status: localClosedIds.has(id) ? 'closed' : (states[id]?.status || 'pending'),
      };
    });

    const allClosed = receiverThreadIds.length > 0
      && receiverThreadIds.every((id) => collabStateTone(states[id]?.status) === 'closed');
    const mergedStatus = allClosed ? 'closed' : (status || (done ? 'completed' : 'running'));

    return {
      id: list[0].id || 'collab-agent-merged',
      name: list[0].name || 'ccweb_mcp_child_agent',
      kind: 'collab_agent_tool_call',
      done,
      input: {
        tool: toolName,
        prompt,
        status: mergedStatus,
        receiverThreadIds,
        agentsStates: states,
      },
    };
  }

  function collabStateTone(statusText) {
    const normalized = String(statusText || '').toLowerCase();
    if (!normalized) return 'pending';
    if (/(closed|close)/.test(normalized)) return 'closed';
    if (/(returned|done|completed|success|finished|idle)/.test(normalized)) return 'done';
    if (/(fail|error|cancel|aborted|rejected)/.test(normalized)) return 'error';
    if (/(running|working|active|inprogress|in_progress|executing)/.test(normalized)) return 'running';
    return 'pending';
  }

  function collabStateLabel(statusText) {
    const normalized = String(statusText || '').trim();
    if (!normalized) return '等待中';
    const lower = normalized.toLowerCase();
    if (/(closed|close)/.test(lower)) return '已关闭';
    if (/(returned)/.test(lower)) return '已返回';
    if (/(done|completed|success|finished)/.test(lower)) return '已返回';
    if (/(fail|error|rejected)/.test(lower)) return '失败';
    if (/(cancel|aborted)/.test(lower)) return '已取消';
    if (/(running|working|active|inprogress|in_progress|executing)/.test(lower)) return '进行中';
    if (/(idle|pending|queued|waiting)/.test(lower)) return '等待中';
    return normalized;
  }

  function createCollabAgentToolElement(tool) {
    const data = normalizeCollabAgentData(tool);
    const wrapper = document.createElement('div');
    wrapper.className = 'tool-call-content collab-agent-content';

    const stack = document.createElement('div');
    stack.className = 'collab-agent-stack';

    const stateEntries = collabAgentStateEntries(data);
    const threadCount = Array.isArray(data.receiverThreadIds) ? data.receiverThreadIds.length : 0;
    const agentCount = stateEntries.length;
    const totalCount = agentCount || threadCount || 0;
    const promptText = summarizePrompt(data.prompt);

    const header = document.createElement('div');
    header.className = 'collab-agent-header';

    const titleWrap = document.createElement('div');
    titleWrap.className = 'collab-agent-title-wrap';

    const kicker = document.createElement('div');
    kicker.className = 'collab-agent-kicker';
    kicker.textContent = '子代';
    titleWrap.appendChild(kicker);

    const title = document.createElement('div');
    title.className = 'collab-agent-title';
    title.textContent = `${totalCount || 0} 个`;
    titleWrap.appendChild(title);

    const meta = document.createElement('div');
    meta.className = 'collab-agent-meta';
    meta.textContent = '';
    if (promptText) meta.title = promptText;
    if (promptText) titleWrap.appendChild(meta);
    header.appendChild(titleWrap);

    const headerActions = document.createElement('div');
    headerActions.className = 'collab-agent-actions';

    const statusChip = document.createElement('span');
    const overallTone = collabStateTone(data.status || (tool.done ? 'completed' : 'running'));
    statusChip.className = `collab-agent-overall-status ${overallTone}`;
    statusChip.textContent = collabStateLabel(data.status || (tool.done ? 'completed' : 'running'));
    headerActions.appendChild(statusChip);

    header.appendChild(headerActions);
    stack.appendChild(header);

    if (stateEntries.length > 0) {
      const list = document.createElement('div');
      list.className = 'collab-agent-list';
      stateEntries.forEach((entry, index) => {
        const tone = collabStateTone(entry.status);
        const item = document.createElement('div');
        item.className = 'collab-agent-item';
        item.title = [
          entry.label || `子代理 ${index + 1}`,
          entry.role ? `角色: ${entry.role}` : '',
          entry.detail ? `结果: ${entry.detail}` : '',
          entry.id ? `ID: ${entry.id}` : '',
        ].filter(Boolean).join('\n');
        if (entry.id) {
          item.setAttribute('role', 'button');
          item.tabIndex = 0;
          item.addEventListener('click', () => {
            copyTextToClipboard(entry.id, '子代理线程 ID 已复制');
          });
          item.addEventListener('keydown', (event) => {
            if (event.key === 'Enter' || event.key === ' ') {
              event.preventDefault();
              copyTextToClipboard(entry.id, '子代理线程 ID 已复制');
            }
          });
        }

        const row = document.createElement('div');
        row.className = 'collab-agent-item-row';

        const label = document.createElement('div');
        label.className = 'collab-agent-item-label';
        label.textContent = !isGenericCollabAgentLabel(entry.label, entry.id)
          ? entry.label
          : `ID ${shortChildAgentId(entry.id || '')}`;
        row.appendChild(label);

        const chip = document.createElement('span');
        chip.className = `collab-agent-item-status ${tone}`;
        chip.textContent = collabStateLabel(entry.status);
        row.appendChild(chip);

        if (entry.id && tone !== 'closed') {
          const closeBtn = document.createElement('button');
          closeBtn.type = 'button';
          closeBtn.className = 'collab-agent-close-btn';
          closeBtn.textContent = '关闭';
          closeBtn.title = `关闭子代理\n${entry.id}`;
          closeBtn.addEventListener('click', (event) => {
            event.stopPropagation();
            closeBtn.disabled = true;
            closeBtn.textContent = '关闭中';
            send({
              type: 'ccweb_mcp_child_agent_close',
              sessionId: currentSessionId,
              threadId: entry.id,
            });
          });
          row.appendChild(closeBtn);
        }
        item.appendChild(row);

        if (entry.id || entry.role) {
          const footer = document.createElement('div');
          footer.className = 'collab-agent-item-footer';
          footer.textContent = entry.role || '';
          if (!footer.textContent) footer.hidden = true;
          item.appendChild(footer);
        }

        list.appendChild(item);
      });
      stack.appendChild(list);
    }

    if (stateEntries.length === 0 && Array.isArray(data.receiverThreadIds) && data.receiverThreadIds.length > 0) {
      const threads = document.createElement('div');
      threads.className = 'collab-agent-threads';
      data.receiverThreadIds.forEach((threadId) => {
        const btn = document.createElement('button');
        btn.type = 'button';
        btn.className = 'collab-agent-thread-chip';
        btn.textContent = `ID ${shortChildAgentId(threadId)}`;
        btn.title = `复制子代理线程 ID\n${threadId}`;
        btn.addEventListener('click', () => {
          copyTextToClipboard(threadId, '子代理线程 ID 已复制');
        });
        threads.appendChild(btn);
      });
      stack.appendChild(threads);
    }

    if (!promptText && stateEntries.length === 0 && (!Array.isArray(data.receiverThreadIds) || data.receiverThreadIds.length === 0)) {
      const empty = document.createElement('div');
      empty.className = 'tool-call-empty';
      empty.textContent = tool.done ? '子代理调用已结束，未返回结构化状态。' : '等待子代理状态…';
      stack.appendChild(empty);
    }

    wrapper.appendChild(stack);
    return wrapper;
  }

  function isGroupableToolCall(node) {
    return !!(node?.classList?.contains('tool-call')
      && node.dataset.toolKind !== 'todo_list'
      && node.dataset.toolKind !== 'collab_agent_tool_call');
  }

  function rememberToolCallTarget(toolUseId, tool, element) {
    if (!element) return;
    const entry = activeToolCalls.get(toolUseId);
    if (entry) {
      entry.domElement = element;
    }
    if (toolKind(tool) === 'todo_list' && tool?.input?.id) {
      activeTodoCallTargets.set(tool.input.id, element);
    }
  }

  function getLatestAssistantToolScope() {
    const streamEl = document.getElementById('streaming-msg');
    if (streamEl) return streamEl;
    const agentSelector = `.msg.assistant.agent-${normalizeAgent(currentAgent)}`;
    const assistantMessages = messagesDiv.querySelectorAll(agentSelector);
    if (assistantMessages.length > 0) {
      return assistantMessages[assistantMessages.length - 1];
    }
    const fallbackMessages = messagesDiv.querySelectorAll('.msg.assistant');
    return fallbackMessages.length > 0 ? fallbackMessages[fallbackMessages.length - 1] : null;
  }

	  function buildMsgElement(m, messageIndex = null) {
	    const el = createMsgElement(m.role, m.content, m.attachments || [], m);
	    if (m.ccwebPrompt) {
	      const bubble = el.querySelector('.msg-bubble');
	      if (bubble) bubble.appendChild(createCcwebPromptElement(m.ccwebPrompt, { sessionId: currentSessionId }));
	    }
	    if (m.role === 'assistant' && m.toolCalls && m.toolCalls.length > 0) {
	      const bubble = el.querySelector('.msg-bubble');
	      const toolMount = bubble.querySelector(':scope > .cross-conversation-reply-body') || bubble;
	      const FOLD_AT = 3;
	      let grouped = false;
	      const mergedCollabTool = mergeCollabAgentTools(m.toolCalls);
	      const renderToolCalls = [
	        ...(mergedCollabTool ? [mergedCollabTool] : []),
	        ...m.toolCalls.filter((tc) => toolKind(tc) !== 'collab_agent_tool_call'),
	      ];
	      for (const tc of renderToolCalls) {
	        if (isEmptyReasoningTool(tc)) continue;
	        const details = createToolCallElement(tc.id || `saved-${Math.random().toString(36).slice(2)}`, tc, true);

	        // 散落的 .tool-call 达到 FOLD_AT 个时，移入唯一 .tool-group
        const loose = Array.from(toolMount.children).filter(isGroupableToolCall);
        if (loose.length >= FOLD_AT) {
          let group = toolMount.querySelector(':scope > .tool-group');
          if (!group) {
            group = document.createElement('details');
            group.className = 'tool-group';
            const gs = document.createElement('summary');
            gs.className = 'tool-group-summary';
            group.appendChild(gs);
            const inner = document.createElement('div');
            inner.className = 'tool-group-inner';
            group.appendChild(inner);
            toolMount.insertBefore(group, toolMount.firstChild);
            grouped = true;
          }
          const inner = group.querySelector('.tool-group-inner');
          loose.forEach(c => inner.appendChild(c));
          _refreshGroupSummary(group);
        }
        toolMount.appendChild(details);
      }
      // 结束时若出现过父目录，收尾散落项
      if (grouped) {
        const loose = Array.from(toolMount.children).filter(isGroupableToolCall);
        if (loose.length > 0) {
          const group = toolMount.querySelector(':scope > .tool-group');
          if (group) {
            const inner = group.querySelector('.tool-group-inner');
            loose.forEach(c => inner.appendChild(c));
            _refreshGroupSummary(group);
          }
        }
      }
    }
    if (Number.isFinite(messageIndex)) {
      markSessionMessageElement(el, messageIndex);
    }
    return el;
  }

  function renderMessages(messages, options = {}) {
    renderEpoch++;
    const epoch = renderEpoch;
    const baseIndex = Number.isFinite(Number(options.baseIndex)) ? Number(options.baseIndex) : 0;
    closedCollabAgentIds = collectClosedCollabAgentIds(messages);
    messagesDiv.innerHTML = '';
    clearUserMessageIndex();
    if (messages.length === 0) {
      messagesDiv.innerHTML = buildWelcomeMarkup(currentAgent);
      updateUserOutlinePanel();
      renderPendingNotes({ scroll: false });
      scrollToBottom();
      return;
    }
    if (options.immediate) {
      const frag = document.createDocumentFragment();
      messages.forEach((message, index) => frag.appendChild(buildMsgElement(message, baseIndex + index)));
      messagesDiv.appendChild(frag);
      updateUserOutlinePanel();
      renderPendingNotes({ scroll: false });
      scrollToBottom();
      return;
    }
    // Batch render: last 10 first, then next 20, then the rest
    const batches = [];
    const len = messages.length;
    if (len <= 10) {
      batches.push([0, len]);
    } else if (len <= 30) {
      batches.push([len - 10, len]);
      batches.push([0, len - 10]);
    } else {
      batches.push([len - 10, len]);
      batches.push([len - 30, len - 10]);
      batches.push([0, len - 30]);
    }

    // Render first batch immediately
    const frag0 = document.createDocumentFragment();
    for (let i = batches[0][0]; i < batches[0][1]; i++) frag0.appendChild(buildMsgElement(messages[i], baseIndex + i));
    messagesDiv.appendChild(frag0);
    updateUserOutlinePanel();
    renderPendingNotes({ scroll: false });
    scrollToBottom();

    // Render remaining batches asynchronously, prepending each
    // Use scrollHeight delta to keep current view position stable after prepend
    let delay = 0;
    for (let b = 1; b < batches.length; b++) {
      const [start, end] = batches[b];
      delay += 16;
      setTimeout(() => {
        if (renderEpoch !== epoch) return; // session switched, abort stale render
        const prevHeight = messagesDiv.scrollHeight;
        const prevScrollTop = messagesDiv.scrollTop;
        const frag = document.createDocumentFragment();
        for (let i = start; i < end; i++) frag.appendChild(buildMsgElement(messages[i], baseIndex + i));
        messagesDiv.insertBefore(frag, messagesDiv.firstChild);
        updateUserOutlinePanel();
        // Compensate scrollTop so visible area stays unchanged
        messagesDiv.scrollTop = prevScrollTop + (messagesDiv.scrollHeight - prevHeight);
        updateScrollbar();
      }, delay);
    }
  }

  function prependHistoryMessages(messages, options = {}) {
    if (!Array.isArray(messages) || messages.length === 0) return;
    const baseIndex = Number.isFinite(Number(options.baseIndex)) ? Number(options.baseIndex) : 0;
    collectClosedCollabAgentIds(messages).forEach((id) => closedCollabAgentIds.add(id));
    const preserveScroll = options.preserveScroll !== false;
    const skipScrollbar = options.skipScrollbar === true;
    const welcome = messagesDiv.querySelector('.welcome-msg');
    if (welcome) welcome.remove();
    const frag = document.createDocumentFragment();
    messages.forEach((m, index) => frag.appendChild(buildMsgElement(m, baseIndex + index)));
    if (!preserveScroll) {
      messagesDiv.insertBefore(frag, messagesDiv.firstChild);
      updateUserOutlinePanel();
      if (!skipScrollbar) updateScrollbar();
      return;
    }
    const prevHeight = messagesDiv.scrollHeight;
    const prevScrollTop = messagesDiv.scrollTop;
    messagesDiv.insertBefore(frag, messagesDiv.firstChild);
    updateUserOutlinePanel();
    messagesDiv.scrollTop = prevScrollTop + (messagesDiv.scrollHeight - prevHeight);
    if (!skipScrollbar) updateScrollbar();
  }

  function normalizeAskUserInput(input) {
    if (input === null || input === undefined) return null;
    if (typeof input === 'string') {
      const trimmed = input.trim();
      if (!trimmed) return null;
      try {
        return JSON.parse(trimmed);
      } catch {
        return null;
      }
    }
    return input;
  }

  function extractAskUserQuestions(input) {
    const parsed = normalizeAskUserInput(input);
    if (!parsed || !Array.isArray(parsed.questions)) return [];
    return parsed.questions;
  }

  function appendAskOptionToInput(question, option) {
    const header = (question?.header || '').trim() || '问题';
    const line = `【${header}】${option?.label || ''}`;
    const current = msgInput.value.trim();
    msgInput.value = current ? `${current}\n${line}` : line;
    autoResize();
    msgInput.focus();
  }

  function createAskUserQuestionView(questions) {
    const wrapper = document.createElement('div');
    wrapper.className = 'ask-user-question';

    questions.forEach((q, idx) => {
      const card = document.createElement('div');
      card.className = 'ask-question-card';

      const header = document.createElement('div');
      header.className = 'ask-question-header';
      header.textContent = `${idx + 1}. ${q.header || '问题'}`;
      card.appendChild(header);

      const body = document.createElement('div');
      body.className = 'ask-question-text';
      body.textContent = q.question || '';
      card.appendChild(body);

      if (Array.isArray(q.options) && q.options.length > 0) {
        const hasDesc = q.options.some(o => o.description);

        // 左右分栏容器
        const layout = document.createElement('div');
        layout.className = 'ask-options-layout' + (hasDesc ? ' has-preview' : '');

        const opts = document.createElement('div');
        opts.className = 'ask-question-options';

        // 右侧预览区（仅在有 description 时创建）
        const preview = hasDesc ? document.createElement('div') : null;
        if (preview) {
          preview.className = 'ask-option-preview';
          // 默认显示第一项
          preview.textContent = q.options[0].description || '';
        }

        // 当前选中项（移动端 tap-to-preview 状态）
        let selectedOpt = null;
        let selectedBtn = null;

        q.options.forEach((opt, i) => {
          const item = document.createElement('button');
          item.type = 'button';
          item.className = 'ask-option-item';

          const title = document.createElement('div');
          title.className = 'ask-option-label';
          title.textContent = `${i + 1}. ${opt.label || ''}`;
          item.appendChild(title);

          // 桌面：hover 切换预览
          if (preview) {
            item.addEventListener('mouseenter', () => {
              preview.textContent = opt.description || '';
            });
          }

          item.addEventListener('click', (e) => {
            const isTouch = item.dataset.touchActivated === '1';
            item.dataset.touchActivated = '';

            if (isTouch) {
              // 移动端：第一次 tap = 选中预览，不发送
              if (selectedBtn !== item) {
                if (selectedBtn) selectedBtn.classList.remove('ask-option-selected');
                selectedBtn = item;
                selectedOpt = opt;
                item.classList.add('ask-option-selected');
                if (preview) preview.textContent = opt.description || '';
                return;
              }
              // 第二次 tap 同一项 = 发送
            }

            // 桌面直接发送
            appendAskOptionToInput(q, opt);
          });

          item.addEventListener('touchstart', () => {
            item.dataset.touchActivated = '1';
          }, { passive: true });

          opts.appendChild(item);
        });

        layout.appendChild(opts);
        if (preview) {
          layout.appendChild(preview);
          // 预览区最小高度 = 左侧选项列表总高度（渲染后同步）
          requestAnimationFrame(() => {
            preview.style.minHeight = opts.offsetHeight + 'px';
          });
        }

        // 移动端确认按钮
        if (hasDesc) {
          const confirmBtn = document.createElement('button');
          confirmBtn.type = 'button';
          confirmBtn.className = 'ask-confirm-btn';
          confirmBtn.textContent = '确认选择';
          confirmBtn.addEventListener('click', () => {
            if (selectedOpt) {
              appendAskOptionToInput(q, selectedOpt);
            } else if (q.options.length > 0) {
              appendAskOptionToInput(q, q.options[0]);
            }
          });
          layout.appendChild(confirmBtn);
        }

        card.appendChild(layout);
      }

      wrapper.appendChild(card);
    });

    return wrapper;
  }

  function buildToolContentElement(name, input) {
    const tool = typeof name === 'object' && name !== null ? name : { name, input };
    const effectiveName = tool.name || name;
    const effectiveInput = tool.input !== undefined ? tool.input : input;
    const effectiveResult = tool.result;
    const kind = toolKind(tool);

    if (kind === 'todo_list') {
      let todoData = effectiveInput;
      // 如果有 result 且是字符串，尝试解析
      if (effectiveResult && typeof effectiveResult === 'string') {
        try {
          todoData = JSON.parse(effectiveResult);
        } catch (e) {
        }
      } else {
      }
      const wrapper = document.createElement('div');
      wrapper.className = 'tool-call-content todo-list-content';
      wrapper.appendChild(createTodoListElement(todoData && typeof todoData === 'object' ? todoData : { items: [] }));
      return wrapper;
    }

    if (kind === 'collab_agent_tool_call') {
      return createCollabAgentToolElement(tool);
    }

    if (effectiveName === 'AskUserQuestion') {
      const questions = extractAskUserQuestions(effectiveInput);
      if (questions.length > 0) {
        return createAskUserQuestionView(questions);
      }
    }

    if (kind === 'command_execution') {
      const wrapper = document.createElement('div');
      wrapper.className = 'tool-call-content command';
      const stack = document.createElement('div');
      stack.className = 'tool-call-structured';
      const commandText = effectiveInput?.command || tool?.meta?.subtitle || '';
      if (commandText) stack.appendChild(buildStructuredToolSection('Command', commandText));
      if (effectiveResult) {
        stack.appendChild(buildStructuredToolSection('Output', stringifyToolValue(effectiveResult)));
      } else if (!tool.done) {
        const empty = document.createElement('div');
        empty.className = 'tool-call-empty';
        empty.textContent = '等待命令输出…';
        stack.appendChild(empty);
      }
      wrapper.appendChild(stack);
      return wrapper;
    }

    if (kind === 'reasoning') {
      const content = document.createElement('div');
      content.className = 'tool-call-content reasoning';
      const text = stringifyToolValue(effectiveResult || effectiveInput);
      content.innerHTML = text ? renderMarkdown(text) : '<div class="tool-call-empty">暂无推理内容</div>';
      return content;
    }

    if (kind === 'file_change' || kind === 'mcp_tool_call') {
      const wrapper = document.createElement('div');
      wrapper.className = `tool-call-content ${kind === 'file_change' ? 'file-change' : ''}`.trim();
      const stack = document.createElement('div');
      stack.className = 'tool-call-structured';
      if (tool?.meta?.subtitle) {
        stack.appendChild(buildStructuredToolSection(kind === 'file_change' ? 'Target' : 'Tool', tool.meta.subtitle));
      }
      const payloadText = stringifyToolValue(effectiveResult || effectiveInput);
      if (payloadText) {
        stack.appendChild(buildStructuredToolSection('Payload', payloadText));
      }
      wrapper.appendChild(stack);
      return wrapper;
    }

    const inputStr = stringifyToolValue(effectiveResult || effectiveInput);
    const content = document.createElement('div');
    content.className = 'tool-call-content';
    content.textContent = inputStr;
    return content;
  }

  function createToolCallElement(toolUseId, tool, done) {
    const kind = toolKind(tool);
    if (kind === 'collab_agent_tool_call') {
      const wrapper = document.createElement('div');
      wrapper.className = 'tool-call ccweb-mcp-child-agent-tool-call collab-agent-inline';
      wrapper.id = `tool-node-${++toolDomSeq}`;
      wrapper.dataset.toolUseId = toolUseId ? String(toolUseId) : '';
      wrapper.dataset.toolName = tool.name || '';
      wrapper.dataset.toolKind = kind;
      wrapper.dataset.childIds = getCollabAgentIdsFromTool(tool).join(',');
      wrapper.appendChild(buildToolContentElement({ ...tool, done }));
      return wrapper;
    }

    const details = document.createElement('details');
    details.className = 'tool-call';
    details.id = `tool-node-${++toolDomSeq}`;
    details.dataset.toolUseId = toolUseId ? String(toolUseId) : '';
    details.dataset.toolName = tool.name || '';
    if (toolKind(tool)) {
      details.dataset.toolKind = toolKind(tool);
      details.classList.add(`codex-${toolKind(tool).replace(/_/g, '-')}`);
    }
    // Default expansion policy:
    // - Always open AskUserQuestion (it is an actionable UI).
    // - For non-Codex sessions, auto-open in-flight command execution so users can watch output.
    // - For Codex sessions, keep everything collapsed by default (less noise), including in-flight commands.
    const agent = normalizeAgent(currentAgent);
    if (tool.name === 'AskUserQuestion') {
      details.open = true;
    } else if (!isCodexLikeAgent(agent) && !done && kind === 'command_execution') {
      details.open = true;
    }

    const summary = document.createElement('summary');
    applyToolSummary(summary, tool, done);
    details.appendChild(summary);
    details.appendChild(buildToolContentElement({ ...tool, done }));
    return details;
  }

  function upsertCollabAgentToolCall(toolsDiv, toolUseId, tool, done) {
    if (!toolsDiv || !tool) return null;
    const existing = toolsDiv.querySelector(':scope > .ccweb-mcp-child-agent-tool-call[data-collab-merged="true"]')
      || toolsDiv.querySelector(':scope > .tool-group .ccweb-mcp-child-agent-tool-call[data-collab-merged="true"]');
    const nextTool = { ...tool, id: toolUseId, done };
    rememberClosedCollabAgentIdsFromTool(nextTool);
    const map = existing?.__collabTools instanceof Map ? existing.__collabTools : new Map();
    map.set(toolUseId || nextTool.id || `collab-${map.size + 1}`, nextTool);
    const merged = mergeCollabAgentTools(Array.from(map.values()));
    if (!merged) return existing;

    if (existing) {
      existing.__collabTools = map;
      existing.dataset.toolUseId = merged.id || existing.dataset.toolUseId || '';
      existing.dataset.childIds = getCollabAgentIdsFromTool(merged).join(',');
      existing.replaceChildren(buildToolContentElement(merged));
      removeDuplicateCollabAgentNodes(toolsDiv);
      return existing;
    }

    const el = createToolCallElement(merged.id, merged, !!merged.done);
    el.dataset.collabMerged = 'true';
    el.dataset.childIds = getCollabAgentIdsFromTool(merged).join(',');
    el.__collabTools = map;
    toolsDiv.appendChild(el);
    removeDuplicateCollabAgentNodes(toolsDiv);
    return el;
  }

  function removeDuplicateCollabAgentNodes(scope) {
    if (!scope) return;
    const seen = new Set();
    const nodes = Array.from(scope.querySelectorAll('.ccweb-mcp-child-agent-tool-call'));
    nodes.forEach((node) => {
      const ids = String(node.dataset.childIds || '').split(',').filter(Boolean);
      const duplicate = ids.length > 0 && ids.some((id) => seen.has(id));
      ids.forEach((id) => seen.add(id));
      if (duplicate) node.remove();
    });
  }

  function appendToolCall(toolUseId, name, input, done, kind = null, meta = null, result = undefined) {
    const streamEl = document.getElementById('streaming-msg');
    if (!streamEl) return;
    const bubble = streamEl.querySelector('.msg-bubble');
    if (!bubble) return;
    const shouldFollow = isNearBottom();
    let toolsDiv = bubble.querySelector('.msg-tools');
    if (!toolsDiv) { toolsDiv = bubble; }

    const tool = { id: toolUseId, name, input, kind, meta, done };
    if (result !== undefined) tool.result = result;
    if (isEmptyReasoningTool(tool)) return;
    if (toolKind(tool) === 'collab_agent_tool_call') {
      const el = upsertCollabAgentToolCall(toolsDiv, toolUseId, tool, done);
      if (el) rememberToolCallTarget(toolUseId, tool, el);
      followOutputIfNeeded(shouldFollow);
      return;
    }

    // 如果是 todo_list，检查是否已存在相同 id 的 todo_list
    if (kind === 'todo_list' && input?.id) {
      const existingTodo = findTodoToolCallByTodoId(toolsDiv, input.id);
      if (existingTodo) {
        const details = createToolCallElement(toolUseId, tool, done);
        existingTodo.replaceWith(details);
        rememberToolCallTarget(toolUseId, tool, details);
        followOutputIfNeeded(shouldFollow);
        return;
      }
    }

    const details = createToolCallElement(toolUseId, tool, done);

    // 折叠策略：只维护唯一一个 .tool-group 父节点
    // 散落的 .tool-call 直接子节点达到3个时，将它们全部移入父节点；之后继续散落，再达3个再移入
    const FOLD_AT = 3;
    const looseBefore = Array.from(toolsDiv.children).filter(isGroupableToolCall);
    if (looseBefore.length >= FOLD_AT) {
      // 确保存在唯一的 .tool-group
      let group = toolsDiv.querySelector(':scope > .tool-group');
      if (!group) {
        group = document.createElement('details');
        group.className = 'tool-group';
        const gs = document.createElement('summary');
        gs.className = 'tool-group-summary';
        group.appendChild(gs);
        const inner = document.createElement('div');
        inner.className = 'tool-group-inner';
        group.appendChild(inner);
        toolsDiv.insertBefore(group, toolsDiv.firstChild);
        hasGrouped = true;
      }
      const inner = group.querySelector('.tool-group-inner');
      looseBefore.forEach(c => inner.appendChild(c));
      _refreshGroupSummary(group);
    }
    toolsDiv.appendChild(details);
    rememberToolCallTarget(toolUseId, tool, details);
    followOutputIfNeeded(shouldFollow);
  }

  function _refreshGroupSummary(group) {
    const inner = group.querySelector('.tool-group-inner');
    const count = inner ? inner.childElementCount : 0;
    const summary = group.querySelector('.tool-group-summary');
    if (summary) summary.textContent = `展开 ${count} 个工具调用`;
  }

  function findLatestToolCallElement(root, matcher) {
    if (!root || typeof matcher !== 'function') return null;
    const allTools = root.querySelectorAll('.tool-call');
    for (let i = allTools.length - 1; i >= 0; i--) {
      const el = allTools[i];
      if (matcher(el)) return el;
    }
    return null;
  }

  function findTodoToolCallByTodoId(root, todoId) {
    if (!todoId) return null;
    return findLatestToolCallElement(root, (el) => {
      if (el.dataset.toolKind !== 'todo_list') return false;
      const currentTodoId = el.querySelector('.todo-list-container')?.dataset.todoId;
      return currentTodoId === todoId;
    });
  }

  function updateToolCall(toolUseId, result, done = true) {
    const tool = activeToolCalls.get(toolUseId) || null;
    const toolUseIdText = toolUseId ? String(toolUseId) : '';
    const scope = getLatestAssistantToolScope();
    if (toolKind(tool) === 'collab_agent_tool_call') {
      const toolsDiv = scope?.querySelector?.('.msg-tools') || scope?.querySelector?.('.msg-bubble') || scope;
      const nextTool = { ...tool, result, done };
      const el = upsertCollabAgentToolCall(toolsDiv, toolUseId, nextTool, done);
      if (el) rememberToolCallTarget(toolUseId, nextTool, el);
      return;
    }
    let el = tool?.domElement && tool.domElement.isConnected ? tool.domElement : null;

    if (!el) {
      if (tool?.kind === 'todo_list' && tool?.input?.id) {
        const markedTodo = activeTodoCallTargets.get(tool.input.id);
        if (markedTodo && markedTodo.isConnected) {
          el = markedTodo;
        }
      }
    }

    if (!el) {
      el = findLatestToolCallElement(scope, (candidate) => candidate.dataset.toolUseId === toolUseIdText);
    }

    if (!el) {
      el = findLatestToolCallElement(messagesDiv, (candidate) => candidate.dataset.toolUseId === toolUseIdText);
    }

    if (!el && tool?.kind === 'todo_list' && tool?.input?.id) {
      el = findTodoToolCallByTodoId(scope, tool.input.id);
    }

    if (!el) {
      return;
    }

    const nextTool = tool || {
      id: toolUseId,
      name: el.dataset.toolName || '',
      kind: el.dataset.toolKind || null,
      done,
    };
    nextTool.done = done;
    if (result !== undefined) nextTool.result = result;
    if (isEmptyReasoningTool(nextTool)) {
      activeToolCalls.delete(toolUseId);
      el.remove();
      return;
    }
    rememberToolCallTarget(toolUseId, nextTool, el);
    const summary = el.querySelector('summary');
    if (summary) applyToolSummary(summary, nextTool, done);
    if (nextTool.name === 'AskUserQuestion') return;
    const nextContent = buildToolContentElement(nextTool);
    const content = Array.from(el.children).find((child) => child.tagName !== 'SUMMARY') || null;
    if (content) {
      content.replaceWith(nextContent);
    } else {
      el.appendChild(nextContent);
    }
  }

  function applyCcwebMcpChildAgentUpdate(msg) {
    const tool = msg?.tool;
    const toolUseId = msg?.toolUseId || tool?.id;
    if (!toolUseId || !tool) return;
    const isCurrentSessionUpdate = msg.sessionId === currentSessionId;
    if (isCurrentSessionUpdate) {
      if (msg?.child?.threadId && collabStateTone(msg.child.status) === 'closed') {
        closedCollabAgentIds.add(String(msg.child.threadId));
      }
      rememberClosedCollabAgentIdsFromTool(tool);
    }

    updateCachedSession(msg.sessionId, (snapshot) => {
      const messages = Array.isArray(snapshot.messages) ? snapshot.messages : [];
      for (let i = messages.length - 1; i >= 0; i -= 1) {
        const calls = Array.isArray(messages[i]?.toolCalls) ? messages[i].toolCalls : [];
        const target = calls.find((item) => item.id === toolUseId);
        if (!target) continue;
        target.name = tool.name || target.name;
        target.kind = tool.kind || target.kind;
        target.input = tool.input !== undefined ? tool.input : target.input;
        target.result = tool.result !== undefined ? tool.result : target.result;
        target.meta = tool.meta || target.meta || null;
        target.done = tool.done !== undefined ? !!tool.done : target.done;
        snapshot.updated = new Date().toISOString();
        break;
      }
    });

    if (!isCurrentSessionUpdate) return;
    activeToolCalls.set(toolUseId, {
      id: toolUseId,
      name: tool.name,
      input: tool.input,
      result: tool.result,
      kind: tool.kind || null,
      meta: tool.meta || null,
      done: !!tool.done,
    });
    updateToolCall(toolUseId, tool.result, !!tool.done);
  }

  function getDeleteConfirmMessage(agent) {
    const normalized = normalizeAgent(agent);
    if (normalized === 'codex') {
      return '删除本会话将同步删去本地 Codex rollout 历史与线程记录，不可恢复。确认删除？';
    }
    if (normalized === 'codexapp') {
      return '删除本会话只会删除 cc-web 中的 Codex App 会话记录，不会清理本地 Codex App 线程历史。确认删除？';
    }
    return '删除本会话将同步删去本地 Claude 中的会话历史，不可恢复。确认删除？';
  }

  function showSimpleConfirm(options = {}) {
    const overlay = document.createElement('div');
    overlay.className = 'settings-overlay';
    overlay.style.zIndex = '10002';

    const box = document.createElement('div');
    box.className = 'settings-panel';
    const title = options.title ? `<div style="font-size:1em;font-weight:700;color:var(--text-primary);margin-bottom:10px">${escapeHtml(options.title)}</div>` : '';
    const confirmText = options.confirmText || '确认';
    const cancelText = options.cancelText || '取消';

    box.innerHTML = `
      ${title}
      <div style="font-size:0.9em;color:var(--text-primary);margin-bottom:20px;line-height:1.7;word-break:break-word;white-space:pre-line">${escapeHtml(options.message || '')}</div>
      <div style="display:flex;flex-direction:column;gap:8px">
        <button id="simple-confirm-ok" style="width:100%;padding:10px;border:none;border-radius:10px;background:var(--accent);color:var(--accent-ink, #fff);font-size:0.95em;font-weight:600;cursor:pointer;font-family:inherit">${escapeHtml(confirmText)}</button>
        <button id="simple-confirm-cancel" style="width:100%;padding:9px;border:none;border-radius:10px;background:transparent;color:var(--text-muted);font-size:0.85em;cursor:pointer;font-family:inherit">${escapeHtml(cancelText)}</button>
      </div>
    `;
    overlay.appendChild(box);
    document.body.appendChild(overlay);

    const close = () => {
      if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
    };
    box.querySelector('#simple-confirm-ok').addEventListener('click', () => {
      close();
      if (typeof options.onConfirm === 'function') options.onConfirm();
    });
    box.querySelector('#simple-confirm-cancel').addEventListener('click', () => {
      close();
      if (typeof options.onCancel === 'function') options.onCancel();
    });
    overlay.addEventListener('click', (e) => {
      if (e.target === overlay) {
        close();
        if (typeof options.onCancel === 'function') options.onCancel();
      }
    });
  }

  function showDeleteConfirm(agent, onConfirm) {
    const overlay = document.createElement('div');
    overlay.className = 'settings-overlay';
    overlay.style.zIndex = '10002';

    const box = document.createElement('div');
    box.className = 'settings-panel';
    box.innerHTML = `
      <div style="font-size:0.9em;color:var(--text-primary);margin-bottom:20px;line-height:1.7">${escapeHtml(getDeleteConfirmMessage(agent))}</div>
      <div style="display:flex;flex-direction:column;gap:8px">
        <button id="del-confirm-ok" style="width:100%;padding:10px;border:none;border-radius:10px;background:var(--accent);color:var(--accent-ink, #fff);font-size:0.95em;font-weight:600;cursor:pointer;font-family:inherit">确认删除</button>
        <button id="del-confirm-skip" style="width:100%;padding:9px;border:1px solid var(--border-color);border-radius:10px;background:var(--bg-tertiary);color:var(--text-secondary);font-size:0.85em;cursor:pointer;font-family:inherit">确认且不再提示</button>
        <button id="del-confirm-cancel" style="width:100%;padding:9px;border:none;border-radius:10px;background:transparent;color:var(--text-muted);font-size:0.85em;cursor:pointer;font-family:inherit">取消</button>
      </div>
    `;
    overlay.appendChild(box);
    document.body.appendChild(overlay);

    const close = () => document.body.removeChild(overlay);
    box.querySelector('#del-confirm-ok').addEventListener('click', () => { close(); onConfirm(); });
    box.querySelector('#del-confirm-skip').addEventListener('click', () => {
      skipDeleteConfirm = true;
      localStorage.setItem('cc-web-skip-delete-confirm', '1');
      close();
      onConfirm();
    });
    box.querySelector('#del-confirm-cancel').addEventListener('click', close);
    overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
  }

  function appendSystemMessage(message, options = {}) {
    const welcome = messagesDiv.querySelector('.welcome-msg');
    if (welcome) welcome.remove();
    messagesDiv.appendChild(createMsgElement('system', message, [], options));
    if (options.preserveScroll !== true) {
      scrollToBottom();
    }
  }

  function appendError(message, options = {}) {
    appendSystemMessage(`⚠ ${message}`, {
      tone: 'danger',
      transient: options.transient !== false,
      autoDismissMs: options.autoDismissMs || 7000,
      preserveScroll: options.preserveScroll !== false,
    });
  }

  function scrollToBottom() {
    requestAnimationFrame(() => {
      messagesDiv.scrollTop = messagesDiv.scrollHeight;
      updateScrollbar();
    });
  }

  function isNearBottom(threshold = 96) {
    const distance = messagesDiv.scrollHeight - messagesDiv.clientHeight - messagesDiv.scrollTop;
    return distance <= threshold;
  }

  function scrollToBottomIfNear(threshold = 96) {
    if (!isNearBottom(threshold)) return false;
    scrollToBottom();
    return true;
  }

  function followOutputIfNeeded(shouldFollow) {
    if (shouldFollow) {
      scrollToBottom();
    } else {
      updateScrollbar();
    }
  }

  // --- Custom Scrollbar ---
  const scrollbarEl = document.getElementById('custom-scrollbar');
  const thumbEl = document.getElementById('custom-scrollbar-thumb');

  function updateScrollbar() {
    if (!scrollbarEl || !thumbEl) return;
    const { scrollTop, scrollHeight, clientHeight } = messagesDiv;
    if (scrollHeight <= clientHeight) {
      thumbEl.style.display = 'none';
      return;
    }
    thumbEl.style.display = '';
    const trackH = scrollbarEl.clientHeight;
    const thumbH = Math.max(30, trackH * clientHeight / scrollHeight);
    const thumbTop = (scrollTop / (scrollHeight - clientHeight)) * (trackH - thumbH);
    thumbEl.style.height = thumbH + 'px';
    thumbEl.style.top = thumbTop + 'px';
  }

  messagesDiv.addEventListener('scroll', () => {
    updateScrollbar();
    // 移动端：滚动时短暂显示滑块，停止后淡出
    scrollbarEl.classList.add('scrolling');
    clearTimeout(scrollbarEl._hideTimer);
    scrollbarEl._hideTimer = setTimeout(() => {
      if (!isDragging) scrollbarEl.classList.remove('scrolling');
    }, 1200);
  }, { passive: true });
  new ResizeObserver(updateScrollbar).observe(messagesDiv);

  // Drag logic
  let dragStartY = 0, dragStartScrollTop = 0, isDragging = false;

  function onDragStart(e) {
    isDragging = true;
    dragStartY = e.type === 'touchstart' ? e.touches[0].clientY : e.clientY;
    dragStartScrollTop = messagesDiv.scrollTop;
    thumbEl.classList.add('dragging');
    scrollbarEl.classList.add('active');
    e.preventDefault();
  }

  function onDragMove(e) {
    if (!isDragging) return;
    const clientY = e.type === 'touchmove' ? e.touches[0].clientY : e.clientY;
    const dy = clientY - dragStartY;
    const { scrollHeight, clientHeight } = messagesDiv;
    const trackH = scrollbarEl.clientHeight;
    const thumbH = Math.max(30, trackH * clientHeight / scrollHeight);
    const ratio = (scrollHeight - clientHeight) / (trackH - thumbH);
    messagesDiv.scrollTop = dragStartScrollTop + dy * ratio;
    e.preventDefault();
  }

  function onDragEnd() {
    if (!isDragging) return;
    isDragging = false;
    thumbEl.classList.remove('dragging');
    scrollbarEl.classList.remove('active');
  }

  thumbEl.addEventListener('mousedown', onDragStart);
  thumbEl.addEventListener('touchstart', onDragStart, { passive: false });
  document.addEventListener('mousemove', onDragMove);
  document.addEventListener('touchmove', onDragMove, { passive: false });
  document.addEventListener('mouseup', onDragEnd);
  document.addEventListener('touchend', onDragEnd);

  updateScrollbar();


  function renderSessionList() {
    sessionList.innerHTML = '';
    syncSessionSearchUi();
    const allVisibleSessions = getVisibleSessions();
    const normalizedSearchQuery = normalizeSessionSearchQuery(sessionSearchQuery);
    const isSearchingSessions = !!normalizedSearchQuery;
    const visibleSessions = isSearchingSessions
      ? allVisibleSessions.filter((session) => sessionMatchesSearch(session, normalizedSearchQuery))
      : allVisibleSessions;
    if (allVisibleSessions.length === 0) {
      const empty = document.createElement('div');
      empty.className = 'session-list-empty';
      empty.textContent = `暂无 ${AGENT_LABELS[currentAgent]} 会话，点击“新会话”开始。`;
      sessionList.appendChild(empty);
      return;
    }
    if (visibleSessions.length === 0) {
      const empty = document.createElement('div');
      empty.className = 'session-list-empty';
      empty.textContent = '没有匹配的会话或项目。';
      sessionList.appendChild(empty);
      return;
    }

    const { pinnedSessions, regularSessions } = splitPinnedSessions(visibleSessions);
    const { groups: projectGroups, ungroupedSessions } = groupSessionsByProject(regularSessions);
    if (pinnedSessions.length > 0) {
      const pinnedGroupEl = document.createElement('section');
      pinnedGroupEl.className = 'session-project-group session-pinned-group';
      const pinnedHeader = document.createElement('div');
      pinnedHeader.className = 'session-project-header session-pinned-header';
      pinnedHeader.innerHTML = `
        <span class="session-project-name">置顶</span>
        <span class="session-project-header-actions">
          <span class="session-project-count">${pinnedSessions.length}</span>
        </span>
      `;
      pinnedGroupEl.appendChild(pinnedHeader);
      for (const session of pinnedSessions) {
        pinnedGroupEl.appendChild(createSessionListItem(session));
      }
      sessionList.appendChild(pinnedGroupEl);
    }

    projectGroups.forEach((group, groupIndex) => {
      const groupKey = getProjectCollapseKey(group);
      const oldSessionCollapseKey = getProjectOldSessionCollapseKey(group);
      const { visibleSessions: visibleGroupSessions, hiddenSessions: hiddenGroupSessions } = isSearchingSessions
        ? { visibleSessions: group.sessions, hiddenSessions: [] }
        : splitCollapsedSessions(group.sessions, oldSessionCollapseKey);
      const isCollapsed = !isSearchingSessions && collapsedProjectKeys.has(groupKey);
      const hasActiveSession = group.sessions.some((session) => session.id === currentSessionId);
      const hasUnreadSession = group.sessions.some((session) => session.hasUnread);
      const hasRunningSession = group.sessions.some((session) => session.isRunning);
      const hasWaitingSession = group.sessions.some((session) => session.waitingOnChildren);
      const groupBodyId = `session-project-body-${groupIndex}`;
      const groupEl = document.createElement('section');
      groupEl.className = `session-project-group${isCollapsed ? ' collapsed' : ''}${hasActiveSession ? ' has-active-session' : ''}${hasUnreadSession ? ' has-unread-session' : ''}${hasRunningSession ? ' has-running-session' : ''}${hasWaitingSession ? ' has-waiting-session' : ''}`;

      const header = document.createElement('div');
      header.className = 'session-project-header';
      header.title = group.cwd || group.name;
      header.innerHTML = `
        <button class="session-project-toggle" type="button" aria-expanded="${isCollapsed ? 'false' : 'true'}" aria-controls="${groupBodyId}" title="${isCollapsed ? '展开项目' : '折叠项目'}">
          <span class="session-project-chevron" aria-hidden="true">${isCollapsed ? '▸' : '▾'}</span>
          <span class="session-project-name">${escapeHtml(group.name)}</span>
        </button>
        <span class="session-project-header-actions">
          <span class="session-project-count">${group.sessions.length}</span>
          <button class="session-project-create" type="button" title="在此项目新建会话" aria-label="在此项目新建会话">+</button>
        </span>
      `;
      groupEl.appendChild(header);

      const groupBody = document.createElement('div');
      groupBody.id = groupBodyId;
      groupBody.className = 'session-project-sessions';
      groupBody.hidden = isCollapsed;
      for (const s of visibleGroupSessions) {
        groupBody.appendChild(createSessionListItem(s));
      }
      if (hiddenGroupSessions.length > 0) {
        groupBody.appendChild(createOldSessionLoadMoreButton(hiddenGroupSessions.length, oldSessionCollapseKey, group.name));
      }
      groupEl.appendChild(groupBody);

      header.querySelector('.session-project-toggle').addEventListener('click', () => {
        setProjectCollapsed(groupKey, !isCollapsed);
      });

      header.querySelector('.session-project-create').addEventListener('click', (e) => {
        e.stopPropagation();
        quickCreateProjectSession(group.cwd || '', { agent: currentAgent, mode: currentMode });
      });

      sessionList.appendChild(groupEl);
    });

    const ungroupedCollapseKey = getUngroupedOldSessionCollapseKey();
    const { visibleSessions: visibleUngroupedSessions, hiddenSessions: hiddenUngroupedSessions } = isSearchingSessions
      ? { visibleSessions: ungroupedSessions, hiddenSessions: [] }
      : splitCollapsedSessions(ungroupedSessions, ungroupedCollapseKey);

    for (const s of visibleUngroupedSessions) {
      sessionList.appendChild(createSessionListItem(s));
    }

    if (hiddenUngroupedSessions.length > 0) {
      sessionList.appendChild(createOldSessionLoadMoreButton(hiddenUngroupedSessions.length, ungroupedCollapseKey));
    }
  }

  function startEditSessionTitle(itemEl, session) {
    const titleEl = itemEl.querySelector('.session-item-title');
    const currentTitle = session.title || '';
    const input = document.createElement('input');
    input.className = 'session-item-edit-input';
    input.value = currentTitle;
    input.maxLength = 100;

    titleEl.replaceWith(input);
    input.focus();
    input.select();

    // Hide actions during edit
    const actions = itemEl.querySelector('.session-item-actions');
    const time = itemEl.querySelector('.session-item-time');
    if (actions) actions.style.display = 'none';
    if (time) time.style.display = 'none';

    function save() {
      const newTitle = input.value.trim() || currentTitle;
      if (newTitle !== currentTitle) {
        send({ type: 'rename_session', sessionId: session.id, title: newTitle });
      }
      // Restore
      const span = document.createElement('span');
      span.className = 'session-item-title';
      span.textContent = newTitle;
      input.replaceWith(span);
      if (actions) actions.style.display = '';
      if (time) time.style.display = '';
    }

    input.addEventListener('blur', save);
    input.addEventListener('keydown', (e) => {
      if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
      if (e.key === 'Escape') { input.value = currentTitle; input.blur(); }
    });
  }

  function highlightActiveSession() {
    document.querySelectorAll('.session-item').forEach((el) => {
      el.classList.toggle('active', el.dataset.id === currentSessionId);
    });
  }

  if (chatSessionIdBtn) {
    chatSessionIdBtn.addEventListener('click', () => {
      copyTextToClipboard(currentSessionId, '当前会话 ID 已复制');
    });
  }

  // --- Header title editing (contenteditable) ---
  chatTitle.addEventListener('click', () => {
    if (!currentSessionId || chatTitle.contentEditable === 'true') return;
    const originalText = chatTitle.textContent;
    chatTitle.contentEditable = 'true';
    chatTitle.style.background = 'var(--surface-strong)';
    chatTitle.style.outline = '1px solid var(--accent)';
    chatTitle.style.borderRadius = '6px';
    chatTitle.style.padding = '2px 8px';
    chatTitle.style.minWidth = '96px';
    chatTitle.style.whiteSpace = 'normal';
    chatTitle.style.overflow = 'visible';
    chatTitle.style.textOverflow = 'clip';
    chatTitle.focus();
    // Select all text
    const range = document.createRange();
    range.selectNodeContents(chatTitle);
    const sel = window.getSelection();
    sel.removeAllRanges();
    sel.addRange(range);

    function finish(save) {
      chatTitle.contentEditable = 'false';
      chatTitle.style.background = '';
      chatTitle.style.outline = '';
      chatTitle.style.borderRadius = '';
      chatTitle.style.padding = '';
      chatTitle.style.minWidth = '';
      chatTitle.style.whiteSpace = '';
      chatTitle.style.overflow = '';
      chatTitle.style.textOverflow = '';
      const newTitle = chatTitle.textContent.trim() || originalText;
      chatTitle.textContent = newTitle;
      if (save && newTitle !== originalText && currentSessionId) {
        send({ type: 'rename_session', sessionId: currentSessionId, title: newTitle });
      }
    }

    chatTitle.addEventListener('blur', () => finish(true), { once: true });
    chatTitle.addEventListener('keydown', function handler(e) {
      if (e.key === 'Enter') { e.preventDefault(); chatTitle.removeEventListener('keydown', handler); chatTitle.blur(); }
      if (e.key === 'Escape') { chatTitle.textContent = originalText; chatTitle.removeEventListener('keydown', handler); chatTitle.blur(); }
    });
  });

  if (chatCwd) {
    chatCwd.addEventListener('click', () => {
      if (!currentCwd) return;
      showFileBrowser();
    });
  }

  // --- Sidebar ---
  function openSidebar() {
    sidebar.classList.add('open');
    sidebarOverlay.hidden = false;
  }
  function closeSidebar() {
    sidebar.classList.remove('open');
    sidebarOverlay.hidden = true;
  }

  function canOpenSidebarBySwipe(target) {
    if (!window.matchMedia('(max-width: 768px), (pointer: coarse)').matches) return false;
    if (sidebar.classList.contains('open')) return false;
    if (sessionLoadingOverlay && !sessionLoadingOverlay.hidden) return false;
    if (!chatMain || !target || !chatMain.contains(target)) return false;
    if (!app.hidden && target && target.closest('input, textarea, select, button, .modal-panel, .settings-panel, .option-picker, .cmd-menu')) {
      return false;
    }
    return true;
  }

  function canCloseSidebarBySwipe(target) {
    if (!window.matchMedia('(max-width: 768px), (pointer: coarse)').matches) return false;
    if (!sidebar.classList.contains('open')) return false;
    if (!target) return false;
    return sidebar.contains(target) || target === sidebarOverlay;
  }

  function handleSidebarSwipeStart(e) {
    if (!e.touches || e.touches.length !== 1) return;
    const touch = e.touches[0];
    if (canCloseSidebarBySwipe(e.target)) {
      sidebarSwipe = {
        startX: touch.clientX,
        startY: touch.clientY,
        active: true,
        mode: 'close',
      };
      return;
    }
    if (!canOpenSidebarBySwipe(e.target)) {
      sidebarSwipe = null;
      return;
    }
    sidebarSwipe = {
      startX: touch.clientX,
      startY: touch.clientY,
      active: true,
      mode: 'open',
    };
  }

  function handleSidebarSwipeMove(e) {
    if (!sidebarSwipe?.active || !e.touches || e.touches.length !== 1) return;
    const touch = e.touches[0];
    const deltaX = touch.clientX - sidebarSwipe.startX;
    const deltaY = touch.clientY - sidebarSwipe.startY;
    if (Math.abs(deltaY) > SIDEBAR_SWIPE_MAX_VERTICAL_DRIFT && Math.abs(deltaY) > Math.abs(deltaX)) {
      sidebarSwipe = null;
      return;
    }
    const horizontalIntent = sidebarSwipe.mode === 'open' ? deltaX > 12 : deltaX < -12;
    if (horizontalIntent && Math.abs(deltaY) < SIDEBAR_SWIPE_MAX_VERTICAL_DRIFT) {
      e.preventDefault();
    }
  }

  function handleSidebarSwipeEnd(e) {
    if (!sidebarSwipe?.active) return;
    const touch = e.changedTouches && e.changedTouches[0];
    const endX = touch ? touch.clientX : sidebarSwipe.startX;
    const endY = touch ? touch.clientY : sidebarSwipe.startY;
    const deltaX = endX - sidebarSwipe.startX;
    const deltaY = endY - sidebarSwipe.startY;
    const shouldOpen = sidebarSwipe.mode === 'open' &&
      deltaX >= SIDEBAR_SWIPE_TRIGGER &&
      Math.abs(deltaY) <= SIDEBAR_SWIPE_MAX_VERTICAL_DRIFT;
    const shouldClose = sidebarSwipe.mode === 'close' &&
      deltaX <= -SIDEBAR_SWIPE_TRIGGER &&
      Math.abs(deltaY) <= SIDEBAR_SWIPE_MAX_VERTICAL_DRIFT;
    sidebarSwipe = null;
    if (shouldOpen) {
      openSidebar();
    } else if (shouldClose) {
      closeSidebar();
    }
  }

  // --- Composer Modifier Menu ---
  function getLocalSlashSuggestions(query) {
    const normalized = String(query || '').replace(/^\//, '').toLowerCase();
    const filtered = SLASH_COMMANDS
      .filter((item) => {
        const cmd = item.cmd.toLowerCase();
        const desc = item.desc.toLowerCase();
        return !normalized || cmd.includes(normalized) || desc.includes(normalized);
      })
      .sort((a, b) => (b.cmd === `/${normalized}` ? 1 : 0) - (a.cmd === `/${normalized}` ? 1 : 0));
    return filtered.map((item) => ({
      kind: 'command',
      name: item.cmd,
      label: item.cmd,
      description: item.desc,
      insertion: `${item.cmd} `,
      appendSpace: false,
    }));
  }

  function findActiveComposerToken() {
    if (!msgInput) return null;
    const value = msgInput.value || '';
    const cursor = typeof msgInput.selectionStart === 'number' ? msgInput.selectionStart : value.length;
    const before = value.slice(0, cursor);

    if (!before.includes('\n') && value.startsWith('/') && cursor > 0) {
      const nextWhitespace = value.search(/\s/);
      const end = nextWhitespace >= 0 ? Math.min(cursor, nextWhitespace) : cursor;
      if (cursor <= end || nextWhitespace < 0) {
        return { trigger: '/', query: value.slice(1, cursor), start: 0, end: cursor };
      }
    }

    const lineStart = Math.max(before.lastIndexOf('\n'), before.lastIndexOf('\r')) + 1;
    const line = before.slice(lineStart);
    const match = line.match(/(^|\s)([@$])([^\s]*)$/);
    if (!match) return null;
    const prefixLength = match[1] ? match[1].length : 0;
    const start = lineStart + match.index + prefixLength;
    return {
      trigger: match[2],
      query: match[3] || '',
      start,
      end: cursor,
    };
  }

  function showCmdMenu(token, items) {
    const safeItems = Array.isArray(items) ? items : [];
    if (!token || safeItems.length === 0) {
      hideCmdMenu();
      return;
    }
    activeComposerToken = token;
    cmdMenuIndex = 0;
    cmdMenu.innerHTML = safeItems.map((item, i) => {
      const kindLabel = item.kind === 'skill'
        ? 'Skill'
        : item.kind === 'prompt'
          ? 'Prompt'
          : item.kind === 'file'
            ? (item.itemType === 'directory' ? 'Dir' : 'File')
            : item.kind === 'mcp'
              ? 'MCP'
              : 'Cmd';
      return `<div class="cmd-item${i === 0 ? ' active' : ''}" data-index="${i}">
        <span class="cmd-item-kind">${kindLabel}</span>
        <span class="cmd-item-main">
          <span class="cmd-item-cmd">${escapeHtml(item.label || item.name || item.insertion || '')}</span>
          <span class="cmd-item-desc">${escapeHtml(item.description || item.title || '')}</span>
        </span>
      </div>`;
    }).join('');
    cmdMenu._items = safeItems;
    cmdMenu.hidden = false;

    cmdMenu.querySelectorAll('.cmd-item').forEach((el) => {
      el.addEventListener('click', () => {
        const index = Number.parseInt(el.dataset.index || '-1', 10);
        selectComposerItemByIndex(index);
      });
    });
  }

  function requestComposerSuggestions() {
    const token = findActiveComposerToken();
    if (!token || noteMode) {
      hideCmdMenu();
      return;
    }

    if (token.trigger === '/') {
      showCmdMenu(token, getLocalSlashSuggestions(token.query));
    }

    clearTimeout(composerSuggestionTimer);
    composerSuggestionTimer = setTimeout(() => {
      const liveToken = findActiveComposerToken();
      if (!liveToken || liveToken.trigger !== token.trigger || liveToken.start !== token.start) {
        hideCmdMenu();
        return;
      }
      const requestId = `composer-${Date.now()}-${++composerRequestSeq}`;
      latestComposerRequestId = requestId;
      activeComposerToken = liveToken;
      send({
        type: 'composer_suggestions',
        requestId,
        trigger: liveToken.trigger,
        query: liveToken.query,
        sessionId: currentSessionId,
        agent: currentAgent,
      });
    }, COMPOSER_SUGGESTION_DEBOUNCE);
  }

  function handleComposerSuggestions(msg) {
    if (!msg || msg.requestId !== latestComposerRequestId) return;
    const token = findActiveComposerToken();
    if (!token || token.trigger !== msg.trigger) {
      hideCmdMenu();
      return;
    }
    showCmdMenu(token, msg.items || []);
  }

  function hideCmdMenu() {
    cmdMenu.hidden = true;
    cmdMenuIndex = -1;
    cmdMenu._items = [];
    activeComposerToken = null;
  }

  function navigateCmdMenu(direction) {
    const items = cmdMenu.querySelectorAll('.cmd-item');
    if (items.length === 0) return;
    items[cmdMenuIndex]?.classList.remove('active');
    cmdMenuIndex = (cmdMenuIndex + direction + items.length) % items.length;
    items[cmdMenuIndex]?.classList.add('active');
    items[cmdMenuIndex]?.scrollIntoView({ block: 'nearest' });
  }

  function selectComposerItemByIndex(index) {
    const items = Array.isArray(cmdMenu._items) ? cmdMenu._items : [];
    const item = items[index];
    if (!item) return;

    if (item.kind === 'command') {
      const cmd = item.name || item.label || '';
      if (cmd === '/model') {
        hideCmdMenu();
        msgInput.value = '';
        showModelPicker();
        return;
      }
      if (cmd === '/mode') {
        hideCmdMenu();
        msgInput.value = '';
        showModePicker();
        return;
      }
      msgInput.value = item.insertion || `${cmd} `;
      hideCmdMenu();
      msgInput.focus();
      autoResize();
      return;
    }

    const token = activeComposerToken || findActiveComposerToken();
    if (!token) return;
    const value = msgInput.value || '';
    const insertion = String(item.insertion || item.label || item.name || '');
    const appendSpace = item.appendSpace !== false;
    const suffix = appendSpace ? ' ' : '';
    msgInput.value = value.slice(0, token.start) + insertion + suffix + value.slice(token.end);
    const nextCursor = token.start + insertion.length + suffix.length;
    msgInput.setSelectionRange(nextCursor, nextCursor);
    hideCmdMenu();
    msgInput.focus();
    autoResize();
    if (!appendSpace) requestComposerSuggestions();
  }

  function selectCmdMenuItem() {
    if (cmdMenuIndex >= 0) selectComposerItemByIndex(cmdMenuIndex);
  }

  // --- Option Picker (generic) ---
  function showOptionPicker(title, options, currentValue, onSelect) {
    hideOptionPicker();

    const picker = document.createElement('div');
    picker.className = 'option-picker';
    picker.id = 'option-picker';

    picker.innerHTML = `
      <div class="option-picker-title">${escapeHtml(title)}</div>
      ${options.map(opt => `
        <div class="option-picker-item${opt.value === currentValue ? ' active' : ''}" data-value="${opt.value}">
          <div class="option-picker-item-info">
            <div class="option-picker-item-label">${escapeHtml(opt.label)}</div>
            <div class="option-picker-item-desc">${escapeHtml(opt.desc)}</div>
          </div>
          ${opt.value === currentValue ? '<span class="option-picker-item-check">✓</span>' : ''}
        </div>
      `).join('')}
    `;

    const chatMain = document.querySelector('.chat-main');
    chatMain.appendChild(picker);

	    picker.querySelectorAll('.option-picker-item').forEach(el => {
	      el.addEventListener('click', () => {
	        // Close current picker first so onSelect can safely open a nested picker.
	        const v = el.dataset.value;
	        hideOptionPicker();
	        onSelect(v);
	      });
	    });

    // Close on outside click (delayed to avoid immediate close)
    setTimeout(() => {
      document.addEventListener('click', _pickerOutsideClick);
    }, 0);
    document.addEventListener('keydown', _pickerEscape);
  }

  function hideOptionPicker() {
    const picker = document.getElementById('option-picker');
    if (picker) picker.remove();
    document.removeEventListener('click', _pickerOutsideClick);
    document.removeEventListener('keydown', _pickerEscape);
  }

  function _pickerOutsideClick(e) {
    const picker = document.getElementById('option-picker');
    if (picker && !picker.contains(e.target)) {
      hideOptionPicker();
    }
  }

  function _pickerEscape(e) {
    if (e.key === 'Escape') {
      hideOptionPicker();
    }
  }

	  function showModelPicker() {
	    if (isCodexLikeAgent(currentAgent)) {
	      const current = _splitCodexThinkingModel(currentModel || '');
	      const baseOptions = getCodexBaseModelOptions();
	      showOptionPicker(`选择 ${isCodexAppAgent(currentAgent) ? 'Codex App' : 'Codex'} 模型`, baseOptions, current.base || '', (baseValue) => {
	        const base = String(baseValue || '').trim();
	        const thinkingOptions = [
	          { value: '', label: '无 (默认)', desc: '不附加 (low/medium/high/xhigh) 后缀' },
	          { value: 'low', label: 'low', desc: '较轻 thinking' },
	          { value: 'medium', label: 'medium', desc: '中等 thinking' },
	          { value: 'high', label: 'high', desc: '更强 thinking' },
	          { value: 'xhigh', label: 'xhigh', desc: '最强 thinking' },
	        ];
	        showOptionPicker('选择 Thinking 强度', thinkingOptions, current.level || '', (lvl) => {
	          const level = String(lvl || '').trim().toLowerCase();
	          const full = level ? `${base}(${level})` : base;
	          send({ type: 'message', text: `/model ${full}`, sessionId: currentSessionId, mode: currentMode, agent: currentAgent });
	        });
	      });
	      return;
	    }
	    showOptionPicker('选择模型', MODEL_OPTIONS, currentModel, (value) => {
	      send({ type: 'message', text: `/model ${value}`, sessionId: currentSessionId, mode: currentMode, agent: currentAgent });
    });
  }

  function showModePicker() {
    showOptionPicker('选择权限模式', MODE_PICKER_OPTIONS, currentMode, (value) => {
      currentMode = value;
      modeSelect.value = currentMode;
      localStorage.setItem(getAgentModeStorageKey(currentAgent), currentMode);
      if (currentSessionId) {
        send({ type: 'set_mode', sessionId: currentSessionId, mode: currentMode });
      }
    });
  }

  // --- Send Message ---
  function submitUserMessage(text, attachments = []) {
    const welcome = messagesDiv.querySelector('.welcome-msg');
    if (welcome) welcome.remove();
    const messageId = createLocalId('user');
    const element = createMsgElement('user', text, attachments, { messageId });
    messagesDiv.appendChild(element);
    if (currentSessionId) {
      const messageIndex = currentSessionMessageCount;
      currentSessionMessageCount += 1;
      markSessionMessageElement(element, messageIndex);
    }
    registerUserMessage(messageId, element, text);
    updateUserOutlinePanel();
    scrollToBottom();

    send({ type: 'message', text, attachments, sessionId: currentSessionId, mode: currentMode, agent: currentAgent });
    startGenerating();
  }

  function sendMessage() {
    const text = msgInput.value.trim();
    if (noteMode) {
      if ((!text && pendingAttachments.length === 0) || isBlockingSessionLoad()) return;
      hideCmdMenu();
      hideOptionPicker();
      if (pendingAttachments.length > 0) {
        appendError('笔记模式暂不支持附带图片，请先移除图片。');
        return;
      }
      if (addPendingNoteFromInput(text)) {
        msgInput.value = '';
        autoResize();
      }
      return;
    }

    const runtimeInsert = isGenerating && isCodexAppAgent(currentAgent);
    if ((!text && pendingAttachments.length === 0) || isBlockingSessionLoad()) return;
    if (isGenerating && !runtimeInsert) return;
    hideCmdMenu();
    hideOptionPicker();

    if (runtimeInsert) {
      if (pendingAttachments.length > 0) {
        appendError('Codex App 运行中插入暂不支持图片附件，请先移除图片。');
        return;
      }
      if (text.startsWith('/')) {
        appendError('Codex App 运行中暂不支持 slash 指令插入。');
        return;
      }
      const messageId = createLocalId('user');
      const element = createMsgElement('user', text, [], { messageId, codexAppSteerStatus: 'pending' });
      const streamEl = document.getElementById('streaming-msg');
      const shouldFollow = isNearBottom();
      if (streamEl && streamEl.parentNode === messagesDiv) {
        messagesDiv.insertBefore(element, streamEl);
      } else {
        messagesDiv.appendChild(element);
      }
      if (currentSessionId) {
        const messageIndex = currentSessionMessageCount;
        currentSessionMessageCount += 1;
        markSessionMessageElement(element, messageIndex);
      }
      registerUserMessage(messageId, element, text);
      updateUserOutlinePanel();
      if (shouldFollow) {
        scrollToBottom();
      } else {
        updateScrollbar();
      }
      send({ type: 'message', text, sessionId: currentSessionId, mode: currentMode, agent: currentAgent, clientMessageId: messageId });
      msgInput.value = '';
      autoResize();
      return;
    }

    // Slash commands: don't show as user bubble
    if (text.startsWith('/')) {
      if (pendingAttachments.length > 0) {
        appendError('命令消息暂不支持附带图片，请先移除图片或发送普通消息。');
        return;
      }
      // /model without argument → show interactive picker
      if (text === '/model' || text === '/model ') {
        showModelPicker();
        msgInput.value = '';
        autoResize();
        return;
      }
      // /mode without argument → show interactive picker
      if (text === '/mode' || text === '/mode ') {
        showModePicker();
        msgInput.value = '';
        autoResize();
        return;
      }
      const requestId = createLocalId('slash');
      rememberPendingSlashDraft(requestId, text, currentSessionId, currentAgent);
      send({ type: 'message', text, sessionId: currentSessionId, mode: currentMode, agent: currentAgent, requestId });
      msgInput.value = '';
      autoResize();
      return;
    }

    // Regular message
    const attachments = pendingAttachments.map((attachment) => ({ ...attachment }));
    submitUserMessage(text, attachments);
    msgInput.value = '';
    pendingAttachments = [];
    renderPendingAttachments();
    autoResize();
  }

  function autoResize() {
    msgInput.style.height = 'auto';
    const max = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--input-max-height')) || 200;
    msgInput.style.height = Math.min(msgInput.scrollHeight, max) + 'px';
  }

  function isMobileInputMode() {
    return window.matchMedia('(max-width: 768px), (pointer: coarse)').matches;
  }

  // --- Event Listeners ---
  loginForm.addEventListener('submit', (e) => {
    e.preventDefault();
    const pw = loginPassword.value;
    if (!pw) return;
    loginError.hidden = true;
    loginPasswordValue = pw;
    // Remember password
    if (rememberPw.checked) {
      localStorage.setItem('cc-web-pw', pw);
    } else {
      localStorage.removeItem('cc-web-pw');
    }
    send({ type: 'auth', password: pw });
    // Request notification permission on first user interaction
    requestNotificationPermission();
  });

  menuBtn.addEventListener('click', () => {
    sidebar.classList.contains('open') ? closeSidebar() : openSidebar();
  });

  sidebarOverlay.addEventListener('click', closeSidebar);
  document.addEventListener('touchstart', handleSidebarSwipeStart, { passive: true });
  document.addEventListener('touchmove', handleSidebarSwipeMove, { passive: false });
  document.addEventListener('touchend', handleSidebarSwipeEnd, { passive: true });
  document.addEventListener('touchcancel', () => { sidebarSwipe = null; }, { passive: true });

  if (chatAgentBtn && chatAgentMenu) {
    chatAgentBtn.addEventListener('click', (e) => {
      e.stopPropagation();
      toggleAgentMenu();
    });
    chatAgentMenu.querySelectorAll('.chat-agent-option').forEach((btn) => {
      btn.addEventListener('click', (e) => {
        e.stopPropagation();
        closeAgentMenu();
        const targetAgent = normalizeAgent(btn.dataset.agent);
        if (targetAgent === currentAgent) return;
        syncViewForAgent(targetAgent, { preserveCurrent: false, loadLast: true });
      });
    });
  }

  if (userOutlineBtn && userOutlinePanel) {
    userOutlineBtn.addEventListener('click', (e) => {
      e.stopPropagation();
      toggleUserOutlinePanel();
    });
    userOutlinePanel.addEventListener('click', (e) => {
      const target = e.target instanceof HTMLElement ? e.target.closest('.user-outline-item') : null;
      if (!target) return;
      const anchorId = target.getAttribute('data-target') || '';
      closeUserOutlinePanel();
      scrollToMessage(anchorId);
    });
  }

  if (ccwebPromptOutlineBtn && ccwebPromptOutlinePanel) {
    ccwebPromptOutlineBtn.addEventListener('click', (e) => {
      e.stopPropagation();
      toggleCcwebPromptOutlinePanel();
    });
    ccwebPromptOutlinePanel.addEventListener('click', (e) => {
      e.stopPropagation();
    });
  }

  if (reloadMcpBtn) {
    reloadMcpBtn.addEventListener('click', (e) => {
      e.stopPropagation();
      reloadCurrentMcpServers();
    });
  }

  if (sessionSearchInput) {
    sessionSearchInput.addEventListener('input', () => {
      sessionSearchQuery = sessionSearchInput.value;
      renderSessionList();
    });
    sessionSearchInput.addEventListener('keydown', (e) => {
      if (e.key === 'Escape' && normalizeSessionSearchQuery(sessionSearchQuery)) {
        e.stopPropagation();
        sessionSearchQuery = '';
        renderSessionList();
        sessionSearchInput.focus();
      }
    });
  }

  if (sessionSearchClear) {
    sessionSearchClear.addEventListener('click', () => {
      sessionSearchQuery = '';
      renderSessionList();
      sessionSearchInput?.focus();
    });
  }

  // Split new-chat button
  newChatBtn.addEventListener('click', () => showNewSessionModal());
  newChatArrow.addEventListener('click', (e) => {
    e.stopPropagation();
    newChatDropdown.hidden = !newChatDropdown.hidden;
  });
  importSessionBtn.addEventListener('click', () => {
    newChatDropdown.hidden = true;
    if (isCodexLikeAgent(currentAgent)) {
      showImportCodexSessionModal();
    } else {
      showImportSessionModal();
    }
  });
  document.addEventListener('click', (e) => {
    if (!newChatDropdown.hidden &&
        !newChatDropdown.contains(e.target) &&
        e.target !== newChatArrow) {
      newChatDropdown.hidden = true;
    }
    if (chatAgentMenu && !chatAgentMenu.hidden &&
        !chatAgentMenu.contains(e.target) &&
        e.target !== chatAgentBtn) {
      closeAgentMenu();
    }
    if (userOutlinePanel && !userOutlinePanel.hidden &&
        !userOutlinePanel.contains(e.target) &&
        e.target !== userOutlineBtn) {
      closeUserOutlinePanel();
    }
    if (ccwebPromptOutlinePanel && !ccwebPromptOutlinePanel.hidden &&
        !ccwebPromptOutlinePanel.contains(e.target) &&
        e.target !== ccwebPromptOutlineBtn) {
      closeCcwebPromptOutlinePanel();
    }
  });
  sendBtn.addEventListener('click', sendMessage);
  if (queueSendBtn) {
    queueSendBtn.addEventListener('click', queueMessageFromInput);
  }
  if (noteModeBtn) {
    noteModeBtn.addEventListener('click', () => {
      noteMode = !noteMode;
      updateNoteModeUI();
      msgInput.focus();
    });
  }
  abortBtn.addEventListener('click', () => send({ type: 'abort' }));
  if (attachBtn && imageUploadInput) {
    attachBtn.addEventListener('click', () => imageUploadInput.click());
    imageUploadInput.addEventListener('change', () => {
      handleSelectedImageFiles(imageUploadInput.files);
    });
  }
  if (inputWrapper) {
    inputWrapper.addEventListener('dragover', (e) => {
      if (!e.dataTransfer?.types?.includes('Files')) return;
      e.preventDefault();
      inputWrapper.classList.add('drag-active');
    });
    inputWrapper.addEventListener('dragleave', (e) => {
      if (e.target === inputWrapper) inputWrapper.classList.remove('drag-active');
    });
    inputWrapper.addEventListener('drop', (e) => {
      e.preventDefault();
      inputWrapper.classList.remove('drag-active');
      handleSelectedImageFiles(e.dataTransfer?.files);
    });
  }

  // Mode selector
  modeSelect.value = currentMode;
  modeSelect.addEventListener('change', () => {
    currentMode = modeSelect.value;
    localStorage.setItem(getAgentModeStorageKey(currentAgent), currentMode);
    if (currentSessionId) {
      send({ type: 'set_mode', sessionId: currentSessionId, mode: currentMode });
    }
  });

  msgInput.addEventListener('input', () => {
    autoResize();
    requestComposerSuggestions();
  });

  msgInput.addEventListener('keydown', (e) => {
    // Command menu navigation
    if (!cmdMenu.hidden) {
      if (e.key === 'ArrowDown') { e.preventDefault(); navigateCmdMenu(1); return; }
      if (e.key === 'ArrowUp') { e.preventDefault(); navigateCmdMenu(-1); return; }
      if (e.key === 'Tab') { e.preventDefault(); selectCmdMenuItem(); return; }
      if (e.key === 'Escape') { hideCmdMenu(); closeSessionActionMenus(); return; }
    }
    if (e.key === 'Escape') {
      closeSessionActionMenus();
    }
    if (e.key === 'Enter' && !e.shiftKey) {
      if (isMobileInputMode()) {
        if (!cmdMenu.hidden) {
          e.preventDefault();
          selectCmdMenuItem();
        }
        return;
      }

      e.preventDefault();
      if (!cmdMenu.hidden) {
        // If menu is open and user presses Enter, select the item
        selectCmdMenuItem();
      } else {
        sendMessage();
      }
    }
  });

  msgInput.addEventListener('paste', (e) => {
    const items = Array.from(e.clipboardData?.items || []);
    const files = items
      .filter((item) => item.kind === 'file' && /^image\//.test(item.type || ''))
      .map((item) => item.getAsFile())
      .filter(Boolean);
    if (files.length > 0) {
      e.preventDefault();
      handleSelectedImageFiles(files);
    }
  });

  // Close cmd menu on outside click
  document.addEventListener('click', (e) => {
    if (!(e.target instanceof Element) || !e.target.closest('.session-item-more')) {
      closeSessionActionMenus();
    }
    if (!cmdMenu.contains(e.target) && e.target !== msgInput) {
      hideCmdMenu();
    }
  });
  document.addEventListener('keydown', (e) => {
    if (e.key === 'Escape') closeSessionActionMenus();
  });

  // --- Toast Notification ---
  function showToast(text, sessionId) {
    const toast = document.createElement('div');
    toast.className = 'toast-notification';
    toast.textContent = text;
    if (sessionId) {
      toast.style.cursor = 'pointer';
      toast.addEventListener('click', () => {
        openSession(sessionId);
        toast.remove();
      });
    }
    document.body.appendChild(toast);
    setTimeout(() => toast.classList.add('show'), 10);
    setTimeout(() => {
      toast.classList.remove('show');
      setTimeout(() => toast.remove(), 300);
    }, 5000);
  }

  // --- Browser Notification (via Service Worker for mobile) ---
  function showBrowserNotification(title) {
    if (!('Notification' in window) || Notification.permission !== 'granted') return;
    if ('serviceWorker' in navigator) {
      navigator.serviceWorker.ready.then((reg) => {
        reg.showNotification('CC-Web', {
          body: `「${title}」任务完成`,
          icon: '/icon-192.png',
          tag: 'cc-web-task',
          renotify: true,
        });
      }).catch(() => {});
    }
  }

  function requestNotificationPermission() {
    if ('Notification' in window && Notification.permission === 'default') {
      Notification.requestPermission();
    }
  }

  // --- Settings Panel ---
  let _onNotifyConfig = null;
  let _onNotifyTestResult = null;
  let _onModelConfig = null;
  let _onCodexConfig = null;
  let _onFetchModelsResult = null;
  let _onCodexSessions = null;

  const settingsBtn = $('#settings-btn');

  const PROVIDER_OPTIONS = [
    { value: 'off', label: '关闭' },
    { value: 'pushplus', label: 'PushPlus' },
    { value: 'telegram', label: 'Telegram' },
    { value: 'serverchan', label: 'Server酱' },
    { value: 'feishu', label: '飞书机器人' },
    { value: 'qqbot', label: 'QQ（Qmsg）' },
  ];

  function buildNotifyFieldsHtml(config, provider) {
    if (provider === 'pushplus') {
      return `
        <div class="settings-field">
          <label>Token</label>
          <input type="text" id="notify-pushplus-token" placeholder="PushPlus Token" value="${escapeHtml(config?.pushplus?.token || '')}">
        </div>
      `;
    }
    if (provider === 'telegram') {
      return `
        <div class="settings-field">
          <label>Bot Token</label>
          <input type="text" id="notify-tg-bottoken" placeholder="123456:ABC-DEF..." value="${escapeHtml(config?.telegram?.botToken || '')}">
        </div>
        <div class="settings-field">
          <label>Chat ID</label>
          <input type="text" id="notify-tg-chatid" placeholder="Chat ID" value="${escapeHtml(config?.telegram?.chatId || '')}">
        </div>
      `;
    }
    if (provider === 'serverchan') {
      return `
        <div class="settings-field">
          <label>SendKey</label>
          <input type="text" id="notify-sc-sendkey" placeholder="Server酱 SendKey" value="${escapeHtml(config?.serverchan?.sendKey || '')}">
        </div>
      `;
    }
    if (provider === 'feishu') {
      return `
        <div class="settings-field">
          <label>Webhook 地址</label>
          <input type="text" id="notify-feishu-webhook" placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/xxx" value="${escapeHtml(config?.feishu?.webhook || '')}">
        </div>
      `;
    }
    if (provider === 'qqbot') {
      return `
        <div class="settings-field">
          <label>Qmsg Key</label>
          <input type="text" id="notify-qmsg-key" placeholder="Qmsg 推送 Key" value="${escapeHtml(config?.qqbot?.qmsgKey || '')}">
        </div>
      `;
    }
    return '';
  }

  function buildAgentContextCard(agent, title, copy) {
    const label = AGENT_LABELS[normalizeAgent(agent)] || AGENT_LABELS.claude;
    return `
      <div class="agent-context-card">
        <div class="agent-context-kicker">${escapeHtml(label)} Space</div>
        <div class="agent-context-title">${escapeHtml(title)}</div>
        <div class="agent-context-copy">${escapeHtml(copy)}</div>
      </div>
    `;
  }

  function renderNotifyFields(fieldsDiv, config, provider) {
    fieldsDiv.innerHTML = buildNotifyFieldsHtml(config, provider);
  }

  function collectNotifyConfigFromPanel(panel, currentConfig, provider) {
    const pp = panel.querySelector('#notify-pushplus-token');
    const tgBot = panel.querySelector('#notify-tg-bottoken');
    const tgChat = panel.querySelector('#notify-tg-chatid');
    const sc = panel.querySelector('#notify-sc-sendkey');
    const feishuWh = panel.querySelector('#notify-feishu-webhook');
    const qmsgKey = panel.querySelector('#notify-qmsg-key');
    // Summary config
    const summaryEnabled = panel.querySelector('#notify-summary-enabled');
    const summaryTrigger = panel.querySelector('#notify-summary-trigger');
    const summarySource = panel.querySelector('#notify-summary-source');
    const summaryApiBase = panel.querySelector('#notify-summary-apibase');
    const summaryApiKey = panel.querySelector('#notify-summary-apikey');
    const summaryModel = panel.querySelector('#notify-summary-model');
    const cs = currentConfig?.summary || {};
    return {
      provider,
      pushplus: { token: pp ? pp.value.trim() : (currentConfig?.pushplus?.token || '') },
      telegram: {
        botToken: tgBot ? tgBot.value.trim() : (currentConfig?.telegram?.botToken || ''),
        chatId: tgChat ? tgChat.value.trim() : (currentConfig?.telegram?.chatId || ''),
      },
      serverchan: { sendKey: sc ? sc.value.trim() : (currentConfig?.serverchan?.sendKey || '') },
      feishu: { webhook: feishuWh ? feishuWh.value.trim() : (currentConfig?.feishu?.webhook || '') },
      qqbot: { qmsgKey: qmsgKey ? qmsgKey.value.trim() : (currentConfig?.qqbot?.qmsgKey || '') },
      summary: {
        enabled: summaryEnabled ? summaryEnabled.checked : !!cs.enabled,
        trigger: summaryTrigger ? summaryTrigger.value : (cs.trigger || 'background'),
        apiSource: summarySource ? summarySource.value : (cs.apiSource || 'claude'),
        apiBase: summaryApiBase ? summaryApiBase.value.trim() : (cs.apiBase || ''),
        apiKey: summaryApiKey ? summaryApiKey.value.trim() : (cs.apiKey || ''),
        model: summaryModel ? summaryModel.value.trim() : (cs.model || ''),
      },
    };
  }

  function buildSummarySettingsHtml(config) {
    const s = config?.summary || {};
    const enabled = !!s.enabled;
    const trigger = s.trigger || 'background';
    const src = s.apiSource || 'claude';
    const customVisible = src === 'custom' ? '' : 'display:none';
    return `
      <div class="settings-divider"></div>
      <div class="settings-section-title">通知摘要</div>
      <div class="settings-field" style="flex-direction:row;align-items:center;gap:10px">
        <label style="margin:0;flex:1">启用 AI 摘要</label>
        <input type="checkbox" id="notify-summary-enabled" ${enabled ? 'checked' : ''} style="width:auto;margin:0">
      </div>
      <div id="notify-summary-options" style="${enabled ? '' : 'display:none'}">
        <div class="settings-field">
          <label>推送时机</label>
          <select class="settings-select" id="notify-summary-trigger">
            <option value="background" ${trigger === 'background' ? 'selected' : ''}>仅后台任务</option>
            <option value="always" ${trigger === 'always' ? 'selected' : ''}>所有任务</option>
          </select>
        </div>
        <div class="settings-field">
          <label>摘要 API 来源</label>
          <select class="settings-select" id="notify-summary-source">
            <option value="claude" ${src === 'claude' ? 'selected' : ''}>Claude 活跃模板</option>
            <option value="codex" ${src === 'codex' ? 'selected' : ''}>Codex 活跃 Profile</option>
            <option value="custom" ${src === 'custom' ? 'selected' : ''}>独立配置</option>
          </select>
        </div>
        <div id="notify-summary-custom" style="${customVisible}">
          <div class="settings-field">
            <label>API Base URL</label>
            <input type="text" id="notify-summary-apibase" placeholder="https://api.example.com" value="${escapeHtml(s.apiBase || '')}">
          </div>
          <div class="settings-field">
            <label>API Key</label>
            <input type="text" id="notify-summary-apikey" placeholder="sk-..." value="${escapeHtml(s.apiKey || '')}">
          </div>
          <div class="settings-field">
            <label>模型</label>
            <input type="text" id="notify-summary-model" placeholder="claude-opus-4-6" value="${escapeHtml(s.model || '')}">
          </div>
        </div>
      </div>
    `;
  }

  function bindSummarySettingsEvents(panel) {
    const enabledCb = panel.querySelector('#notify-summary-enabled');
    const optionsDiv = panel.querySelector('#notify-summary-options');
    const sourceSelect = panel.querySelector('#notify-summary-source');
    const customDiv = panel.querySelector('#notify-summary-custom');
    if (!enabledCb || !optionsDiv || !sourceSelect || !customDiv) return;
    enabledCb.addEventListener('change', () => {
      optionsDiv.style.display = enabledCb.checked ? '' : 'none';
    });
    sourceSelect.addEventListener('change', () => {
      customDiv.style.display = sourceSelect.value === 'custom' ? '' : 'none';
    });
  }

  function openPasswordModal() {
    const pwOverlay = document.createElement('div');
    pwOverlay.className = 'settings-overlay';
    pwOverlay.style.zIndex = '10001';
    const pwModal = document.createElement('div');
    pwModal.className = 'settings-panel';
    pwModal.style.maxWidth = '400px';
    pwModal.innerHTML = `
      <div class="settings-header">
        <h3>修改密码</h3>
        <button class="settings-close" id="pw-modal-close">&times;</button>
      </div>
      <div class="settings-field">
        <label>当前密码</label>
        <input type="password" id="pw-modal-current" placeholder="当前密码" autocomplete="current-password">
      </div>
      <div class="settings-field">
        <label>新密码</label>
        <input type="password" id="pw-modal-new" placeholder="新密码" autocomplete="new-password">
        <div class="password-hint" id="pw-modal-hint">至少 8 位，包含大写/小写/数字/特殊字符中的 2 种</div>
      </div>
      <div class="settings-field">
        <label>确认新密码</label>
        <input type="password" id="pw-modal-confirm" placeholder="确认新密码" autocomplete="new-password">
      </div>
      <div class="settings-actions">
        <button class="btn-save" id="pw-modal-submit" disabled>修改密码</button>
      </div>
      <div class="settings-status" id="pw-modal-status"></div>
    `;
    pwOverlay.appendChild(pwModal);
    document.body.appendChild(pwOverlay);

    const currentPwIn = pwModal.querySelector('#pw-modal-current');
    const newPwIn = pwModal.querySelector('#pw-modal-new');
    const confirmPwIn = pwModal.querySelector('#pw-modal-confirm');
    const hint = pwModal.querySelector('#pw-modal-hint');
    const submitBtn = pwModal.querySelector('#pw-modal-submit');
    const status = pwModal.querySelector('#pw-modal-status');

    function checkPw() {
      const newPw = newPwIn.value;
      const confirmPw = confirmPwIn.value;
      const currentPw = currentPwIn.value;
      if (!newPw) {
        hint.textContent = '至少 8 位，包含大写/小写/数字/特殊字符中的 2 种';
        hint.className = 'password-hint';
        submitBtn.disabled = true;
        return;
      }
      const result = clientValidatePassword(newPw);
      if (!result.valid) {
        hint.textContent = result.message;
        hint.className = 'password-hint error';
        submitBtn.disabled = true;
        return;
      }
      hint.textContent = '密码强度符合要求';
      hint.className = 'password-hint success';
      submitBtn.disabled = !currentPw || !confirmPw || confirmPw !== newPw;
    }

    currentPwIn.addEventListener('input', checkPw);
    newPwIn.addEventListener('input', checkPw);
    confirmPwIn.addEventListener('input', checkPw);

    const closePwModal = () => { document.body.removeChild(pwOverlay); };
    pwModal.querySelector('#pw-modal-close').addEventListener('click', closePwModal);
    pwOverlay.addEventListener('click', (e) => { if (e.target === pwOverlay) closePwModal(); });

    submitBtn.addEventListener('click', () => {
      const currentPw = currentPwIn.value;
      const newPw = newPwIn.value;
      const confirmPw = confirmPwIn.value;
      if (newPw !== confirmPw) {
        status.textContent = '两次密码不一致';
        status.className = 'settings-status error';
        return;
      }
      submitBtn.disabled = true;
      status.textContent = '正在修改...';
      status.className = 'settings-status';
      _onPasswordChanged = (result) => {
        if (result.success) {
          status.textContent = result.message || '密码修改成功';
          status.className = 'settings-status success';
          setTimeout(closePwModal, 1200);
        } else {
          status.textContent = result.message || '修改失败';
          status.className = 'settings-status error';
          submitBtn.disabled = false;
        }
      };
      send({ type: 'change_password', currentPassword: currentPw, newPassword: newPw });
    });

    currentPwIn.focus();
  }

  function showCodexSettingsPanel() {
    send({ type: 'get_codex_config' });
    send({ type: 'get_notify_config' });

    const overlay = document.createElement('div');
    overlay.className = 'settings-overlay';
    overlay.id = 'settings-overlay';

    const panel = document.createElement('div');
    panel.className = 'settings-panel';
    panel.innerHTML = `
      <h3>
        ⚙ Codex 设置
        <button class="settings-close" title="关闭">&times;</button>
      </h3>

      <div class="settings-section-title">Codex 运行配置</div>
      <div class="settings-field">
        <label>配置模式</label>
        <select class="settings-select" id="codex-mode">
          <option value="local">读取本机 Codex 登录态 / ~/.codex/config.toml</option>
          <option value="custom">自定义 API Profile</option>
        </select>
      </div>
      <div id="codex-profile-area"></div>

      <div class="settings-divider"></div>
      <div class="settings-section-title">容量失败重试</div>
      <div class="settings-field">
        <label>自动重试</label>
        <select class="settings-select" id="codex-retry-mode">
          <option value="limited">按次数重试</option>
          <option value="forever">一直重试</option>
          <option value="off">关闭</option>
        </select>
      </div>
      <div class="settings-retry-grid">
        <div class="settings-field">
          <label>间隔（秒）</label>
          <input type="number" id="codex-retry-interval" min="1" max="3600" step="1" inputmode="numeric">
        </div>
        <div class="settings-field" id="codex-retry-attempts-field">
          <label>重试次数</label>
          <input type="number" id="codex-retry-attempts" min="1" max="1000" step="1" inputmode="numeric">
        </div>
      </div>
      <div class="settings-inline-note" id="codex-retry-note">
        仅对没有产生文本或工具调用的 Codex 容量/过载失败生效，避免重复执行已有副作用的任务。
      </div>

      <div class="settings-actions">
        <button class="btn-save" id="codex-save-btn">保存 Codex 配置</button>
      </div>
      <div class="settings-status" id="codex-status"></div>

      <div class="settings-divider"></div>

      ${buildAppearanceSettingsHtml()}

      <div class="settings-divider"></div>

      ${buildNotifyEntryHtml(null)}

      <div class="settings-divider"></div>

	      <div class="settings-section-title">系统</div>
	      <div class="settings-actions" style="margin-top:0;flex-wrap:wrap;gap:10px">
	        <button class="btn-test" id="pw-open-modal-btn" style="padding:6px 16px">修改密码</button>
	        <button class="btn-test" id="check-update-btn" style="padding:6px 16px">检查更新</button>
	      </div>
	      <div class="settings-status" id="update-status" style="margin-top:8px"></div>
	    `;

    overlay.appendChild(panel);
    document.body.appendChild(overlay);
    mountAppearanceSettings(panel);
    const notifyPageBtn = panel.querySelector('[data-open-notify-page]');
    if (notifyPageBtn) notifyPageBtn.addEventListener('click', openNotifySubpage);

    const closeBtn = panel.querySelector('.settings-close');
    const codexModeSelect = panel.querySelector('#codex-mode');
    const codexProfileArea = panel.querySelector('#codex-profile-area');
    const codexRetryModeSelect = panel.querySelector('#codex-retry-mode');
    const codexRetryIntervalInput = panel.querySelector('#codex-retry-interval');
    const codexRetryAttemptsInput = panel.querySelector('#codex-retry-attempts');
    const codexRetryAttemptsField = panel.querySelector('#codex-retry-attempts-field');
    const codexRetryNote = panel.querySelector('#codex-retry-note');
    const codexStatus = panel.querySelector('#codex-status');
    const codexSaveBtn = panel.querySelector('#codex-save-btn');

	    const pwOpenModalBtn = panel.querySelector('#pw-open-modal-btn');
	    const checkUpdateBtn = panel.querySelector('#check-update-btn');
	    const updateStatusEl = panel.querySelector('#update-status');

    let currentCodexConfig = null;
    let codexEditingProfiles = [];
    let codexActiveProfile = '';
    let codexRetryConfig = { mode: 'limited', intervalSeconds: 2, maxAttempts: 3 };
    let _onUpdateInfo = null;

    function showCodexStatus(msg, type) {
      codexStatus.textContent = msg;
      codexStatus.className = 'settings-status ' + (type || '');
    }

    function normalizeCodexRetryConfig(raw = {}) {
      const mode = ['off', 'limited', 'forever'].includes(raw.mode) ? raw.mode : 'limited';
      const intervalSeconds = Math.max(1, Math.min(3600, Number.parseInt(String(raw.intervalSeconds || ''), 10) || 2));
      const maxAttempts = Math.max(1, Math.min(1000, Number.parseInt(String(raw.maxAttempts || ''), 10) || 3));
      return { mode, intervalSeconds, maxAttempts };
    }

    function syncCodexRetryInputs() {
      const mode = codexRetryModeSelect.value;
      const disabled = mode === 'off';
      codexRetryIntervalInput.disabled = disabled;
      codexRetryAttemptsInput.disabled = disabled || mode === 'forever';
      codexRetryAttemptsField.classList.toggle('settings-field-disabled', disabled || mode === 'forever');
      codexRetryNote.textContent = mode === 'off'
        ? '已关闭自动重试；容量/过载失败会直接显示错误。'
        : mode === 'forever'
          ? '会一直按固定间隔重试；仍只在没有文本或工具调用时触发。'
          : '按指定次数重试；仍只在没有文本或工具调用时触发，避免重复执行副作用。';
    }

    function setCodexRetryConfig(config) {
      codexRetryConfig = normalizeCodexRetryConfig(config);
      codexRetryModeSelect.value = codexRetryConfig.mode;
      codexRetryIntervalInput.value = String(codexRetryConfig.intervalSeconds);
      codexRetryAttemptsInput.value = String(codexRetryConfig.maxAttempts);
      syncCodexRetryInputs();
    }

    function readCodexRetryConfig() {
      return normalizeCodexRetryConfig({
        mode: codexRetryModeSelect.value,
        intervalSeconds: codexRetryIntervalInput.value,
        maxAttempts: codexRetryAttemptsInput.value,
      });
    }

    function renderCodexProfileArea() {
      const mode = codexModeSelect.value;
      if (mode === 'local') {
        codexProfileArea.innerHTML = `
          <div class="settings-inline-note">
            当前将直接复用本机 <code>codex</code> 的登录态与 <code>~/.codex/config.toml</code>。这适合你已经在终端里正常使用 Codex 的场景。
          </div>
        `;
        return;
      }

      if (codexEditingProfiles.length === 0) {
        codexProfileArea.innerHTML = `
          <div class="settings-inline-note">
            自定义模式适合接 OpenAI 兼容服务，例如你提到的第三方 API 入口。这里仅覆盖 <strong>API Key</strong> 和 <strong>API Base URL</strong>，不会让配置页随意改模型 ID。
          </div>
          <div class="settings-actions" style="margin-top:0">
            <button class="btn-test" id="codex-profile-add-first">+ 新建 Profile</button>
          </div>
        `;
        panel.querySelector('#codex-profile-add-first').addEventListener('click', () => openCodexProfileModal());
        return;
      }

      const options = codexEditingProfiles.map((profile) =>
        `<option value="${escapeHtml(profile.name)}" ${profile.name === codexActiveProfile ? 'selected' : ''}>${escapeHtml(profile.name)}</option>`
      ).join('');
      const currentProfile = codexEditingProfiles.find((profile) => profile.name === codexActiveProfile) || codexEditingProfiles[0];
      if (currentProfile && !codexActiveProfile) codexActiveProfile = currentProfile.name;
      const summaryBase = currentProfile?.apiBase ? escapeHtml(currentProfile.apiBase) : '未设置 API Base URL';

      codexProfileArea.innerHTML = `
        <div class="settings-inline-note">
          自定义模式会为 cc-web 生成独立的 Codex 运行配置，只覆盖当前激活 Profile 的 <strong>API Key</strong> 与 <strong>API Base URL</strong>，不去碰你平时终端里用的全局登录态。
        </div>
        <div class="settings-field">
          <label>激活 Profile</label>
          <div style="display:flex;gap:6px;align-items:center">
            <select class="settings-select" id="codex-profile-select" style="flex:1">
              ${options}
              <option value="__new__">+ 新建 Profile</option>
            </select>
            <button class="btn-test" id="codex-profile-edit" style="padding:4px 10px">编辑</button>
            <button class="btn-test" id="codex-profile-del" title="删除" style="padding:4px 8px">删除</button>
          </div>
        </div>
        <div class="settings-inline-note">
          当前 Profile：<strong>${escapeHtml(currentProfile?.name || '未选择')}</strong><br>
          API Base URL：<code>${summaryBase}</code>
        </div>
      `;

      panel.querySelector('#codex-profile-select').addEventListener('change', (e) => {
        if (e.target.value === '__new__') {
          openCodexProfileModal();
          return;
        }
        codexActiveProfile = e.target.value;
        renderCodexProfileArea();
      });

      panel.querySelector('#codex-profile-edit').addEventListener('click', () => {
        openCodexProfileModal(codexActiveProfile);
      });

      panel.querySelector('#codex-profile-del').addEventListener('click', () => {
        if (!codexActiveProfile) return;
        if (!confirm(`确认删除 Codex Profile「${codexActiveProfile}」?`)) return;
        codexEditingProfiles = codexEditingProfiles.filter((profile) => profile.name !== codexActiveProfile);
        codexActiveProfile = codexEditingProfiles[0]?.name || '';
        renderCodexProfileArea();
      });
    }

    function openCodexProfileModal(profileName = '') {
      const current = profileName
        ? codexEditingProfiles.find((profile) => profile.name === profileName)
        : null;
      const draft = current || { name: '', apiKey: '', apiBase: '' };

      const modalOverlay = document.createElement('div');
      modalOverlay.className = 'settings-overlay';
      modalOverlay.style.zIndex = '10001';
      const modal = document.createElement('div');
      modal.className = 'settings-panel';
      modal.style.maxWidth = '460px';
      modal.innerHTML = `
        <div class="settings-header">
          <h3>${current ? `编辑 Profile: ${escapeHtml(current.name)}` : '新建 Codex Profile'}</h3>
          <button class="settings-close" id="codex-profile-modal-close">&times;</button>
        </div>
        <div class="settings-field">
          <label>Profile 名称</label>
          <input type="text" id="codex-profile-name" placeholder="例如 OpenRouter Work" value="${escapeHtml(draft.name || '')}">
        </div>
        <div class="settings-field">
          <label>API Key</label>
          <input type="text" id="codex-profile-apikey" placeholder="sk-..." value="${escapeHtml(draft.apiKey || '')}">
        </div>
        <div class="settings-field">
          <label>API Base URL</label>
          <input type="text" id="codex-profile-apibase" placeholder="https://api.openai.com/v1" value="${escapeHtml(draft.apiBase || '')}">
        </div>
        <div class="settings-inline-note">
          这里不开放模型 ID 编辑。Codex 仍使用上方“默认模型”以及会话内的模型切换逻辑，只把 API 入口和密钥切换到当前 Profile。
        </div>
        <div class="settings-actions">
          <button class="btn-save" id="codex-profile-ok">确定</button>
        </div>
      `;
      modalOverlay.appendChild(modal);
      document.body.appendChild(modalOverlay);

      const closeModal = () => document.body.removeChild(modalOverlay);
      modal.querySelector('#codex-profile-modal-close').addEventListener('click', closeModal);
      modalOverlay.addEventListener('click', (e) => { if (e.target === modalOverlay) closeModal(); });

      modal.querySelector('#codex-profile-ok').addEventListener('click', () => {
        const name = modal.querySelector('#codex-profile-name').value.trim();
        const apiKey = modal.querySelector('#codex-profile-apikey').value.trim();
        const apiBase = modal.querySelector('#codex-profile-apibase').value.trim();
        if (!name) {
          alert('请填写 Profile 名称');
          return;
        }
        if (!apiKey) {
          alert('请填写 API Key');
          return;
        }
        if (!apiBase) {
          alert('请填写 API Base URL');
          return;
        }
        const existing = codexEditingProfiles.find((profile) => profile.name === name);
        if (existing && existing !== current) {
          alert('Profile 名称已存在');
          return;
        }
        if (current) {
          current.name = name;
          current.apiKey = apiKey;
          current.apiBase = apiBase;
        } else {
          codexEditingProfiles.push({ name, apiKey, apiBase });
        }
        codexActiveProfile = name;
        closeModal();
        renderCodexProfileArea();
      });
    }

    _onCodexConfig = (config) => {
      currentCodexConfig = config || {};
      codexModeSelect.value = currentCodexConfig.mode || 'local';
      codexEditingProfiles = (currentCodexConfig.profiles || []).map((profile) => ({ ...profile }));
      codexActiveProfile = currentCodexConfig.activeProfile || (codexEditingProfiles[0]?.name || '');
      setCodexRetryConfig(currentCodexConfig.retry || codexRetryConfig);
      renderCodexProfileArea();
    };

    codexModeSelect.addEventListener('change', renderCodexProfileArea);
    codexRetryModeSelect.addEventListener('change', syncCodexRetryInputs);
    setCodexRetryConfig(codexRetryConfig);

    codexSaveBtn.addEventListener('click', () => {
      if (codexModeSelect.value === 'custom' && codexEditingProfiles.length === 0) {
        showCodexStatus('自定义模式至少需要一个 Codex Profile', 'error');
        return;
      }
      const config = {
        mode: codexModeSelect.value,
        activeProfile: codexActiveProfile,
        profiles: codexEditingProfiles,
        enableSearch: false,
        retry: readCodexRetryConfig(),
      };
      send({ type: 'save_codex_config', config });
      showCodexStatus('已保存', 'success');
    });

    pwOpenModalBtn.addEventListener('click', openPasswordModal);

    checkUpdateBtn.addEventListener('click', () => {
      updateStatusEl.textContent = '正在检查...';
      updateStatusEl.className = 'settings-status';
      _onUpdateInfo = (info) => {
        _onUpdateInfo = null;
        if (info.error) {
          updateStatusEl.textContent = '检查失败: ' + info.error;
          updateStatusEl.className = 'settings-status error';
          return;
        }
        if (info.hasUpdate) {
          updateStatusEl.innerHTML = `有新版本 <strong>v${escapeHtml(info.latestVersion)}</strong>（当前 v${escapeHtml(info.localVersion)}）&nbsp;<a href="${escapeHtml(info.releaseUrl)}" target="_blank" style="color:var(--accent)">查看更新</a>`;
          updateStatusEl.className = 'settings-status success';
        } else {
          updateStatusEl.textContent = `已是最新版本 v${info.localVersion}`;
          updateStatusEl.className = 'settings-status success';
        }
      };
      send({ type: 'check_update' });
    });

    window._ccOnUpdateInfo = (info) => { if (_onUpdateInfo) _onUpdateInfo(info); };

    closeBtn.addEventListener('click', hideSettingsPanel);
    overlay.addEventListener('click', (e) => { if (e.target === overlay) hideSettingsPanel(); });
    document.addEventListener('keydown', _settingsEscape);
  }

  function showSettingsPanel() {
    setCurrentAgent(PRIMARY_UI_AGENT);
    if (isCodexLikeAgent(currentAgent)) {
      showCodexSettingsPanel();
      return;
    }
    // Request current configs (notify config is loaded on demand inside subpage)
    send({ type: 'get_model_config' });
    send({ type: 'get_notify_config' });

    const overlay = document.createElement('div');
    overlay.className = 'settings-overlay';
    overlay.id = 'settings-overlay';

    const panel = document.createElement('div');
    panel.className = 'settings-panel';

    panel.innerHTML = `
      <h3>
        ⚙ Claude 设置
        <button class="settings-close" title="关闭">&times;</button>
      </h3>

      <div class="settings-section-title">Claude 配置</div>
      <div class="settings-field">
        <label>配置模式</label>
        <select class="settings-select" id="model-mode">
          <option value="local">读取本地配置文件 (~/.claude.json)</option>
          <option value="custom">自定义配置</option>
        </select>
      </div>
      <div id="model-custom-area"></div>
      <div class="settings-actions" id="model-actions" style="display:none">
        <button class="btn-save" id="model-save-btn">保存模型配置</button>
      </div>
      <div class="settings-status" id="model-status"></div>

      <div class="settings-divider"></div>

      ${buildAppearanceSettingsHtml()}

      <div class="settings-divider"></div>

      ${buildNotifyEntryHtml(null)}

      <div class="settings-divider"></div>

      <div class="settings-section-title">系统</div>
      <div class="settings-actions" style="margin-top:0;flex-wrap:wrap;gap:10px">
        <button class="btn-test" id="pw-open-modal-btn" style="padding:6px 16px">修改密码</button>
        <button class="btn-test" id="check-update-btn" style="padding:6px 16px">检查更新</button>
      </div>
      <div class="settings-status" id="update-status" style="margin-top:8px"></div>
    `;

    overlay.appendChild(panel);
    document.body.appendChild(overlay);
    mountAppearanceSettings(panel);
    const notifyPageBtn2 = panel.querySelector('[data-open-notify-page]');
    if (notifyPageBtn2) notifyPageBtn2.addEventListener('click', openNotifySubpage);

    // === Model Config UI ===
    const modelModeSelect = panel.querySelector('#model-mode');
    const modelCustomArea = panel.querySelector('#model-custom-area');
    const modelActionsDiv = panel.querySelector('#model-actions');
    const modelSaveBtn = panel.querySelector('#model-save-btn');
    const modelStatusDiv = panel.querySelector('#model-status');

    let modelCurrentConfig = null;
    let modelEditingTemplates = [];
    let modelActiveTemplate = '';

    function showModelStatus(msg, type) {
      modelStatusDiv.textContent = msg;
      modelStatusDiv.className = 'settings-status ' + (type || '');
    }

    function renderModelCustomArea() {
      if (modelModeSelect.value === 'local') {
        modelCustomArea.innerHTML = `<div class="settings-field" style="color:var(--text-warning, #e8a838);font-size:0.85em">⚠ 使用自定义模板会覆盖本地 API 配置，请提前做好备份。</div>`;
        modelActionsDiv.style.display = 'flex';
      } else {
        renderModelTemplateEditor();
        modelActionsDiv.style.display = 'flex';
      }
    }

    function renderModelTemplateEditor() {
      const activeName = modelActiveTemplate;
      const tpl = modelEditingTemplates.find(t => t.name === activeName) || null;
      const tplOptions = modelEditingTemplates.map(t =>
        `<option value="${escapeHtml(t.name)}" ${t.name === activeName ? 'selected' : ''}>${escapeHtml(t.name)}</option>`
      ).join('');

      if (modelEditingTemplates.length === 0) {
        modelCustomArea.innerHTML = `
          <div class="settings-field" style="color:var(--text-secondary);font-size:0.85em">尚无模板，点击下方按钮新建。</div>
          <div class="settings-actions" style="margin-top:0">
            <button class="btn-test" id="model-tpl-add-first">+ 新建模板</button>
          </div>
        `;
        panel.querySelector('#model-tpl-add-first').addEventListener('click', () => {
          const newName = prompt('输入新模板名称:');
          if (!newName || !newName.trim()) return;
          const n = newName.trim();
          modelEditingTemplates.push({ name: n, apiKey: '', apiBase: '', defaultModel: '', opusModel: '', sonnetModel: '', haikuModel: '' });
          modelActiveTemplate = n;
          renderModelTemplateEditor();
        });
        return;
      }

      modelCustomArea.innerHTML = `
        <div class="settings-field">
          <label>激活模板</label>
          <div style="display:flex;gap:6px;align-items:center">
            <select class="settings-select" id="model-tpl-select" style="flex:1">
              ${tplOptions}
              <option value="__new__">+ 新建模板</option>
            </select>
            <button class="btn-test" id="model-tpl-edit" style="padding:4px 10px">编辑</button>
            <button class="btn-test" id="model-tpl-del" title="删除" style="padding:4px 8px">删除</button>
          </div>
        </div>
      `;

      panel.querySelector('#model-tpl-select').addEventListener('change', (e) => {
        if (e.target.value === '__new__') {
          const newName = prompt('输入新模板名称:');
          if (!newName || !newName.trim()) { e.target.value = modelActiveTemplate; return; }
          const n = newName.trim();
          if (modelEditingTemplates.find(t => t.name === n)) { alert('模板名称已存在'); e.target.value = modelActiveTemplate; return; }
          modelEditingTemplates.push({ name: n, apiKey: '', apiBase: '', defaultModel: '', opusModel: '', sonnetModel: '', haikuModel: '' });
          modelActiveTemplate = n;
          renderModelTemplateEditor();
          openTplEditModal();
        } else {
          modelActiveTemplate = e.target.value;
          renderModelTemplateEditor();
        }
      });

      panel.querySelector('#model-tpl-edit').addEventListener('click', () => {
        openTplEditModal();
      });

      const delBtn = panel.querySelector('#model-tpl-del');
      if (delBtn) {
        delBtn.addEventListener('click', () => {
          if (!modelActiveTemplate) return;
          if (!confirm(`确认删除模板「${modelActiveTemplate}」?`)) return;
          modelEditingTemplates = modelEditingTemplates.filter(t => t.name !== modelActiveTemplate);
          modelActiveTemplate = modelEditingTemplates[0]?.name || '';
          renderModelTemplateEditor();
        });
      }
    }

    function openTplEditModal() {
      const tpl = modelEditingTemplates.find(t => t.name === modelActiveTemplate);
      if (!tpl) return;

      const modalOverlay = document.createElement('div');
      modalOverlay.className = 'settings-overlay';
      modalOverlay.style.zIndex = '10001';
      const modal = document.createElement('div');
      modal.className = 'settings-panel';
      modal.style.maxWidth = '460px';
      modal.innerHTML = `
        <div class="settings-header">
          <h3>编辑模板: ${escapeHtml(tpl.name)}</h3>
          <button class="settings-close" id="tpl-modal-close">&times;</button>
        </div>
        <div class="settings-field">
          <label>模板名称</label>
          <input type="text" id="tpl-ed-name" value="${escapeHtml(tpl.name)}">
        </div>
        <div class="settings-field">
          <label>API Key</label>
          <input type="text" id="tpl-ed-apikey" placeholder="sk-ant-..." value="${escapeHtml(tpl.apiKey || '')}">
        </div>
        <div class="settings-field">
          <label>API Base URL</label>
          <input type="text" id="tpl-ed-apibase" placeholder="https://api.anthropic.com" value="${escapeHtml(tpl.apiBase || '')}">
        </div>

        <div class="settings-divider" style="margin:12px 0"></div>

        <div class="settings-field">
          <label style="display:flex;align-items:center;gap:8px;font-weight:600">
            获取上游模型列表
          </label>
          <div style="display:flex;gap:6px;align-items:center;margin-top:4px">
            <label style="font-size:0.85em;display:flex;align-items:center;gap:4px;cursor:pointer">
              <input type="checkbox" id="tpl-ed-custom-endpoint"> 端点
            </label>
            <input type="text" id="tpl-ed-models-endpoint" placeholder="/v1/models" style="flex:1;display:none" value="">
          </div>
          <div style="display:flex;gap:6px;margin-top:6px;align-items:center">
            <button class="btn-test" id="tpl-ed-fetch-models" style="padding:4px 12px;white-space:nowrap">获取模型</button>
            <span id="tpl-ed-fetch-status" style="font-size:0.85em;color:var(--text-secondary)"></span>
          </div>
        </div>

        <div class="settings-divider" style="margin:12px 0"></div>

        <div class="settings-field">
          <label>默认模型 (ANTHROPIC_MODEL)</label>
          <input type="text" id="tpl-ed-default" list="tpl-dl-models" placeholder="claude-opus-4-6" value="${escapeHtml(tpl.defaultModel || '')}" autocomplete="off">
        </div>
        <div class="settings-field">
          <label>Opus 模型名</label>
          <input type="text" id="tpl-ed-opus" list="tpl-dl-models" placeholder="claude-opus-4-6" value="${escapeHtml(tpl.opusModel || '')}" autocomplete="off">
        </div>
        <div class="settings-field">
          <label>Sonnet 模型名</label>
          <input type="text" id="tpl-ed-sonnet" list="tpl-dl-models" placeholder="claude-sonnet-4-6" value="${escapeHtml(tpl.sonnetModel || '')}" autocomplete="off">
        </div>
        <div class="settings-field">
          <label>Haiku 模型名</label>
          <input type="text" id="tpl-ed-haiku" list="tpl-dl-models" placeholder="claude-haiku-4-5-20251001" value="${escapeHtml(tpl.haikuModel || '')}" autocomplete="off">
        </div>
        <datalist id="tpl-dl-models"></datalist>
        <div class="settings-actions">
          <button class="btn-save" id="tpl-ed-ok">确定</button>
        </div>
      `;
      modalOverlay.appendChild(modal);
      document.body.appendChild(modalOverlay);

      // Custom endpoint checkbox toggle
      const customEndpointCb = modal.querySelector('#tpl-ed-custom-endpoint');
      const endpointInput = modal.querySelector('#tpl-ed-models-endpoint');
      customEndpointCb.addEventListener('change', () => {
        endpointInput.style.display = customEndpointCb.checked ? '' : 'none';
      });

      // Fetch models
      const fetchBtn = modal.querySelector('#tpl-ed-fetch-models');
      const fetchStatus = modal.querySelector('#tpl-ed-fetch-status');
      const datalist = modal.querySelector('#tpl-dl-models');

      fetchBtn.addEventListener('click', () => {
        const apiBase = modal.querySelector('#tpl-ed-apibase').value.trim();
        const apiKey = modal.querySelector('#tpl-ed-apikey').value.trim();
        if (!apiBase || !apiKey) {
          fetchStatus.textContent = '请先填写 API Base 和 API Key';
          fetchStatus.style.color = 'var(--text-error, #e85d5d)';
          return;
        }
        const modelsEndpoint = customEndpointCb.checked ? endpointInput.value.trim() : '';
        fetchBtn.disabled = true;
        fetchStatus.textContent = '正在获取...';
        fetchStatus.style.color = 'var(--text-secondary)';

        _onFetchModelsResult = (result) => {
          _onFetchModelsResult = null;
          fetchBtn.disabled = false;
          if (result.success) {
            datalist.innerHTML = result.models.map(m => `<option value="${escapeHtml(m)}">`).join('');
            fetchStatus.textContent = `获取到 ${result.models.length} 个模型`;
            fetchStatus.style.color = 'var(--text-success, #5dbe5d)';
          } else {
            fetchStatus.textContent = result.message || '获取失败';
            fetchStatus.style.color = 'var(--text-error, #e85d5d)';
          }
        };

        send({ type: 'fetch_models', apiBase, apiKey, modelsEndpoint: modelsEndpoint || undefined, templateName: tpl.name });
      });

      const closeModal = () => {
        _onFetchModelsResult = null;
        document.body.removeChild(modalOverlay);
      };
      modal.querySelector('#tpl-modal-close').addEventListener('click', closeModal);
      modalOverlay.addEventListener('click', (e) => { if (e.target === modalOverlay) closeModal(); });

      modal.querySelector('#tpl-ed-ok').addEventListener('click', () => {
        const newName = modal.querySelector('#tpl-ed-name').value.trim();
        if (newName && newName !== tpl.name) {
          if (modelEditingTemplates.find(t => t.name === newName && t !== tpl)) { alert('模板名称已存在'); return; }
          tpl.name = newName;
          modelActiveTemplate = newName;
        }
        tpl.apiKey = modal.querySelector('#tpl-ed-apikey').value.trim();
        tpl.apiBase = modal.querySelector('#tpl-ed-apibase').value.trim();
        tpl.defaultModel = modal.querySelector('#tpl-ed-default').value.trim();
        tpl.opusModel = modal.querySelector('#tpl-ed-opus').value.trim();
        tpl.sonnetModel = modal.querySelector('#tpl-ed-sonnet').value.trim();
        tpl.haikuModel = modal.querySelector('#tpl-ed-haiku').value.trim();
        closeModal();
        renderModelTemplateEditor();
      });
    }

    function saveTplFields() {
      // Fields are now saved via modal, no inline fields to read
    }

    modelModeSelect.addEventListener('change', renderModelCustomArea);

    modelSaveBtn.addEventListener('click', () => {
      if (modelModeSelect.value === 'custom') saveTplFields();
      const config = {
        mode: modelModeSelect.value,
        activeTemplate: modelActiveTemplate,
        templates: modelEditingTemplates,
      };
      send({ type: 'save_model_config', config });
      showModelStatus('已保存', 'success');
    });

    _onModelConfig = (config) => {
      modelCurrentConfig = config;
      modelEditingTemplates = (config.templates || []).map(t => Object.assign({}, t));
      modelActiveTemplate = config.activeTemplate || (modelEditingTemplates[0]?.name || '');
      modelModeSelect.value = config.mode || 'local';
      renderModelCustomArea();
    };

	    // === Notify Config UI (moved to subpage) ===
	    // notify config is handled by openNotifySubpage()

	    const closeBtn = panel.querySelector('.settings-close');
	    const pwOpenModalBtn = panel.querySelector('#pw-open-modal-btn');
	    pwOpenModalBtn.addEventListener('click', openPasswordModal);

    // Check update button
    const checkUpdateBtn = panel.querySelector('#check-update-btn');
    const updateStatusEl = panel.querySelector('#update-status');
    let _onUpdateInfo = null;
    checkUpdateBtn.addEventListener('click', () => {
      updateStatusEl.textContent = '正在检查...';
      updateStatusEl.className = 'settings-status';
      _onUpdateInfo = (info) => {
        _onUpdateInfo = null;
        if (info.error) {
          updateStatusEl.textContent = '检查失败: ' + info.error;
          updateStatusEl.className = 'settings-status error';
          return;
        }
        if (info.hasUpdate) {
          updateStatusEl.innerHTML = `有新版本 <strong>v${escapeHtml(info.latestVersion)}</strong>（当前 v${escapeHtml(info.localVersion)}）&nbsp;<a href="${escapeHtml(info.releaseUrl)}" target="_blank" style="color:var(--accent)">查看更新</a>`;
          updateStatusEl.className = 'settings-status success';
        } else {
          updateStatusEl.textContent = `已是最新版本 v${info.localVersion}`;
          updateStatusEl.className = 'settings-status success';
        }
      };
      send({ type: 'check_update' });
    });

    // Wire _onUpdateInfo into WS handler via closure
    const _origOnUpdateInfo = window._ccOnUpdateInfo;
    window._ccOnUpdateInfo = (info) => { if (_onUpdateInfo) _onUpdateInfo(info); };

    closeBtn.addEventListener('click', hideSettingsPanel);
    overlay.addEventListener('click', (e) => { if (e.target === overlay) hideSettingsPanel(); });

    document.addEventListener('keydown', _settingsEscape);
  }

  function hideSettingsPanel() {
    const overlay = document.getElementById('settings-overlay');
    if (overlay) overlay.remove();
    document.querySelectorAll('.settings-subpage-overlay').forEach((node) => node.remove());
    _onNotifyConfig = null;
    _onNotifyTestResult = null;
    _onModelConfig = null;
    _onCodexConfig = null;
    _onFetchModelsResult = null;
    window._ccOnUpdateInfo = null;
    document.removeEventListener('keydown', _settingsEscape);
  }

  function _settingsEscape(e) {
    if (e.key === 'Escape') hideSettingsPanel();
  }

  if (settingsBtn) {
    settingsBtn.addEventListener('click', showSettingsPanel);
  }

  // --- Force Change Password ---
  function showForceChangePassword() {
    const overlay = document.createElement('div');
    overlay.className = 'force-change-overlay';
    overlay.id = 'force-change-overlay';

    const panel = document.createElement('div');
    panel.className = 'force-change-panel';

    panel.innerHTML = `
      <img class="login-logo" src="icon-192.png" alt="CC-Web">
      <h2>修改初始密码</h2>
      <p>首次登录需要设置新密码</p>
      <div class="force-change-form">
        <input type="password" id="fc-new-pw" placeholder="新密码" autocomplete="new-password">
        <div class="password-hint" id="fc-hint">至少 8 位，包含大写/小写/数字/特殊字符中的 2 种</div>
        <input type="password" id="fc-confirm-pw" placeholder="确认新密码" autocomplete="new-password">
        <button id="fc-submit-btn" class="fc-submit-btn" disabled>确认修改</button>
        <div class="fc-status" id="fc-status"></div>
      </div>
    `;

    overlay.appendChild(panel);
    document.body.appendChild(overlay);

    const newPwInput = panel.querySelector('#fc-new-pw');
    const confirmPwInput = panel.querySelector('#fc-confirm-pw');
    const hintEl = panel.querySelector('#fc-hint');
    const submitBtn = panel.querySelector('#fc-submit-btn');
    const statusEl = panel.querySelector('#fc-status');

    function checkStrength() {
      const pw = newPwInput.value;
      const confirm = confirmPwInput.value;
      if (!pw) {
        hintEl.textContent = '至少 8 位，包含大写/小写/数字/特殊字符中的 2 种';
        hintEl.className = 'password-hint';
        submitBtn.disabled = true;
        return;
      }
      const result = clientValidatePassword(pw);
      if (!result.valid) {
        hintEl.textContent = result.message;
        hintEl.className = 'password-hint error';
        submitBtn.disabled = true;
        return;
      }
      hintEl.textContent = '密码强度符合要求';
      hintEl.className = 'password-hint success';
      submitBtn.disabled = !confirm || confirm !== pw;
    }

    newPwInput.addEventListener('input', checkStrength);
    confirmPwInput.addEventListener('input', checkStrength);

    submitBtn.addEventListener('click', () => {
      const newPw = newPwInput.value;
      const confirmPw = confirmPwInput.value;
      if (newPw !== confirmPw) {
        statusEl.textContent = '两次密码不一致';
        statusEl.className = 'fc-status error';
        return;
      }
      submitBtn.disabled = true;
      statusEl.textContent = '正在修改...';
      statusEl.className = 'fc-status';
      send({ type: 'change_password', currentPassword: loginPasswordValue || localStorage.getItem('cc-web-pw') || '', newPassword: newPw });
    });

    newPwInput.focus();
  }

  function hideForceChangePassword() {
    const overlay = document.getElementById('force-change-overlay');
    if (overlay) overlay.remove();
  }

  function clientValidatePassword(pw) {
    if (!pw || pw.length < 8) {
      return { valid: false, message: '密码长度至少 8 位' };
    }
    let types = 0;
    if (/[a-z]/.test(pw)) types++;
    if (/[A-Z]/.test(pw)) types++;
    if (/[0-9]/.test(pw)) types++;
    if (/[^a-zA-Z0-9]/.test(pw)) types++;
    if (types < 2) {
      return { valid: false, message: '需包含至少 2 种字符类型（大写/小写/数字/特殊字符）' };
    }
    return { valid: true, message: '' };
  }

  // --- Password Changed Handler ---
  let _onPasswordChanged = null;

  function handlePasswordChanged(msg) {
    if (msg.success) {
      // Update token
      authToken = msg.token;
      localStorage.setItem('cc-web-token', msg.token);
      // Update remembered password
      if (localStorage.getItem('cc-web-pw')) {
        // Clear old remembered password since it's changed
        localStorage.removeItem('cc-web-pw');
      }

      // If force-change overlay is open, close it and load sessions
      const fcOverlay = document.getElementById('force-change-overlay');
      if (fcOverlay) {
        hideForceChangePassword();
        syncViewForAgent(currentAgent, { preserveCurrent: false, loadLast: true });
        showToast('密码修改成功');
      }

      // If settings panel change password
      if (_onPasswordChanged) {
        _onPasswordChanged({ success: true, message: msg.message });
        _onPasswordChanged = null;
      }
    } else {
      // Force-change error
      const fcStatus = document.querySelector('#fc-status');
      if (fcStatus) {
        fcStatus.textContent = msg.message || '修改失败';
        fcStatus.className = 'fc-status error';
        const btn = document.querySelector('#fc-submit-btn');
        if (btn) btn.disabled = false;
      }

      // Settings panel error
      if (_onPasswordChanged) {
        _onPasswordChanged({ success: false, message: msg.message });
        _onPasswordChanged = null;
      }
    }
  }

  // --- Recent CWD memory (localStorage) ---
  const RECENT_CWD_KEY = 'cc-web-recent-cwds';
  const RECENT_CWD_MAX = 5;

  function getRecentCwds() {
    try {
      const raw = localStorage.getItem(RECENT_CWD_KEY);
      return raw ? JSON.parse(raw) : [];
    } catch { return []; }
  }

  function saveRecentCwd(cwd) {
    if (!cwd) return;
    let list = getRecentCwds().filter(p => p !== cwd);
    list.unshift(cwd);
    if (list.length > RECENT_CWD_MAX) list = list.slice(0, RECENT_CWD_MAX);
    try { localStorage.setItem(RECENT_CWD_KEY, JSON.stringify(list)); } catch {}
  }

  function branchFromAssistantMessage(messageIndex) {
    if (!currentSessionId) {
      appendError('当前没有可分支的会话。', { transient: true, autoDismissMs: 4000 });
      return;
    }
    if (!Number.isFinite(messageIndex) || messageIndex < 0) {
      appendError('无法定位分支消息，请刷新会话后重试。', { transient: true, autoDismissMs: 4000 });
      return;
    }
    const sourceTitle = (chatTitle.textContent || '新会话').trim() || '新会话';
    const cwd = currentCwd || getSessionEffectiveCwd(currentSessionId) || null;
    requestNewSession({
      cwd,
      rawCwd: cwd || '',
      agent: currentAgent,
      mode: currentMode,
      model: currentModel || '',
      title: `${sourceTitle} 的分支`,
      branchSourceSessionId: currentSessionId,
      branchMessageIndex: messageIndex,
    });
  }

  function requestNewSession(options = {}) {
    const cwd = options.cwd || null;
    const rawCwd = options.rawCwd !== undefined ? options.rawCwd : (cwd || '');
    const agent = normalizeUiAgent(options.agent || currentAgent);
    const mode = ['default', 'plan', 'yolo'].includes(options.mode) ? options.mode : currentMode;
    const model = typeof options.model === 'string' ? options.model.trim() : '';
    const title = typeof options.title === 'string' ? options.title.trim() : '';
    const branchSourceSessionId = String(options.branchSourceSessionId || '').trim();
    const branchMessageIndex = Number.isFinite(Number(options.branchMessageIndex))
      ? Number(options.branchMessageIndex)
      : null;
    const requestId = createSessionSwitchRequestId('new');
    pendingNewSessionRequest = {
      cwd,
      rawCwd,
      agent,
      mode,
      model,
      title,
      branchSourceSessionId,
      branchMessageIndex,
      requestId,
    };
    if (cwd) saveRecentCwd(cwd);
    const payload = { type: 'new_session', cwd, agent, mode, requestId };
    if (model) payload.model = model;
    if (title) payload.title = title;
    if (branchSourceSessionId) payload.branchSourceSessionId = branchSourceSessionId;
    if (branchMessageIndex !== null) payload.branchMessageIndex = branchMessageIndex;
    send(payload);
  }

  // --- New Session Modal ---
  let _onCwdSuggestions = null;

  function showNewSessionModal(options = {}) {
    const targetAgent = normalizeUiAgent(options.agent || currentAgent);
    const recentCwds = getRecentCwds();
    const requestedMode = ['default', 'plan', 'yolo'].includes(options.mode) ? options.mode : currentMode;
    let suggestionsRequested = false;
    let suggestionState = {
      defaultPath: '',
      paths: [],
    };
    const overlay = document.createElement('div');
    overlay.className = 'modal-overlay';
    overlay.id = 'new-session-overlay';

    overlay.innerHTML = `
      <div class="modal-panel">
        <div class="modal-header">
          <span class="modal-title">新建会话</span>
          <button class="modal-close-btn" id="ns-close-btn">✕</button>
        </div>
        <div class="modal-body">
          <div class="modal-stack">
            <div>
              <label class="modal-field-label" for="ns-cwd-input">工作目录</label>
              <div class="modal-field-row">
                <input type="text" id="ns-cwd-input" class="modal-text-input" placeholder="例如 /home/user/project" list="ns-cwd-list" autocomplete="off">
                <button type="button" class="modal-btn-secondary modal-btn-inline" id="ns-pick-dir-btn">选择目录</button>
              </div>
              <div class="modal-field-hint" id="ns-cwd-tip">正在准备默认目录…</div>
              <div class="modal-quick-picks" id="ns-cwd-picks"></div>
              <datalist id="ns-cwd-list"></datalist>
            </div>
          </div>
        </div>
        <div class="modal-footer">
          <button class="modal-btn-secondary" id="ns-cancel-btn">取消</button>
          <button class="modal-btn-primary" id="ns-create-btn">创建</button>
        </div>
      </div>
    `;

    document.body.appendChild(overlay);

    const cwdInput = overlay.querySelector('#ns-cwd-input');
    const cwdList = overlay.querySelector('#ns-cwd-list');
    const cwdTip = overlay.querySelector('#ns-cwd-tip');
    const cwdPicks = overlay.querySelector('#ns-cwd-picks');
    const createBtn = overlay.querySelector('#ns-create-btn');
    const pickDirBtn = overlay.querySelector('#ns-pick-dir-btn');

    cwdInput.value = String(options.cwd || recentCwds[0] || '').trim();

    function getMergedCwdSuggestions() {
      const seen = new Set();
      const merged = [];
      for (const candidate of [...recentCwds, suggestionState.defaultPath, ...(suggestionState.paths || [])]) {
        const pathValue = String(candidate || '').trim();
        if (!pathValue || seen.has(pathValue)) continue;
        seen.add(pathValue);
        merged.push(pathValue);
      }
      return merged;
    }

    function getEffectiveCwd() {
      return cwdInput.value.trim() || suggestionState.defaultPath || null;
    }

    function renderCwdOptions() {
      const merged = getMergedCwdSuggestions();
      cwdList.innerHTML = merged
        .map((pathValue, index) => `<option value="${escapeHtml(pathValue)}"${index < recentCwds.length ? ' label="最近"' : ''}></option>`)
        .join('');

      const quickPickPaths = merged.slice(0, 6);
      cwdPicks.innerHTML = quickPickPaths.map((pathValue) => `
        <button
          type="button"
          class="modal-quick-pick"
          data-path="${escapeHtml(pathValue)}"
          title="${escapeHtml(pathValue)}"
        >${escapeHtml(pathValue)}</button>
      `).join('');

      cwdPicks.querySelectorAll('.modal-quick-pick').forEach((button) => {
        button.addEventListener('click', () => {
          const pathValue = button.dataset.path || '';
          if (!pathValue) return;
          cwdInput.value = pathValue;
          cwdInput.focus();
        });
      });

      const fallbackPath = suggestionState.defaultPath || '';
      cwdTip.textContent = fallbackPath
        ? `留空时默认使用 ${fallbackPath}`
        : '可手动输入路径，也可以点按钮选择目录';
    }

    function requestCwdSuggestions() {
      if (suggestionsRequested) return;
      suggestionsRequested = true;
      _onCwdSuggestions = (payload) => {
        suggestionState = {
          defaultPath: String(payload?.defaultPath || '').trim(),
          paths: Array.isArray(payload?.paths) ? payload.paths : [],
        };
        if (!cwdInput.value.trim()) {
          cwdInput.value = recentCwds[0] || suggestionState.defaultPath || '';
        }
        renderCwdOptions();
      };
      send({ type: 'list_cwd_suggestions' });
    }

    renderCwdOptions();
    requestCwdSuggestions();

    function createSession() {
      const cwd = getEffectiveCwd();
      const rawCwd = cwdInput.value.trim();
      close();
      requestNewSession({
        cwd,
        rawCwd,
        agent: targetAgent,
        mode: requestedMode,
        model: options.model || '',
        title: options.title || '',
        branchSourceSessionId: options.branchSourceSessionId || '',
        branchMessageIndex: options.branchMessageIndex,
      });
    }

    pickDirBtn.addEventListener('click', () => {
      showDirectoryPicker({
        title: '选择工作目录',
        initialPath: getEffectiveCwd() || '',
        onChoose: (selectedPath) => {
          cwdInput.value = selectedPath;
          cwdInput.focus();
        },
      });
    });

    cwdInput.addEventListener('focus', () => {
      if (!suggestionState.defaultPath && (!suggestionState.paths || suggestionState.paths.length === 0)) {
        requestCwdSuggestions();
      }
    });
    cwdInput.addEventListener('keydown', (e) => {
      if (e.key === 'Enter') {
        e.preventDefault();
        createSession();
      }
    });

    function close() {
      closeDirectoryPicker();
      overlay.remove();
      _onCwdSuggestions = null;
    }

    overlay.querySelector('#ns-close-btn').addEventListener('click', close);
    overlay.querySelector('#ns-cancel-btn').addEventListener('click', close);
    overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
    createBtn.addEventListener('click', createSession);

    cwdInput.focus();
  }

  function quickCreateProjectSession(cwd, options = {}) {
    const targetCwd = String(cwd || '').trim();
    requestNewSession({
      cwd: targetCwd || null,
      rawCwd: targetCwd,
      agent: options.agent || currentAgent,
      mode: options.mode || currentMode,
    });
  }

  // --- Import Native Session Modal ---
  let _onNativeSessions = null;

  function appendImportVisibilityToggle(body, options) {
    const hiddenCount = Number(options?.hiddenCount || 0);
    if (!body || hiddenCount <= 0) return;
    const row = document.createElement('label');
    row.className = 'import-filter-row';
    row.innerHTML = `
      <span class="import-filter-copy">
        <span class="import-filter-title">显示已导入会话</span>
        <span class="import-filter-meta">已隐藏 ${hiddenCount} 个 cc-web 已存在的会话</span>
      </span>
      <span class="settings-switch">
        <input type="checkbox" ${options.showImported ? 'checked' : ''}>
        <span class="settings-switch-track" aria-hidden="true">
          <span class="settings-switch-thumb"></span>
        </span>
      </span>
    `;
    const input = row.querySelector('input');
    input.addEventListener('change', () => options.onToggle(!!input.checked));
    body.appendChild(row);
  }

  function showImportSessionModal() {
    if (currentAgent !== 'claude') return;
    let nativeGroups = [];
    let showImported = false;
    const overlay = document.createElement('div');
    overlay.className = 'modal-overlay';
    overlay.id = 'import-session-overlay';

    overlay.innerHTML = `
      <div class="modal-panel modal-panel-wide">
        <div class="modal-header">
          <span class="modal-title">导入本地 CLI 会话</span>
          <button class="modal-close-btn" id="is-close-btn">✕</button>
        </div>
        <div class="modal-body" id="is-body">
          ${buildAgentContextCard('claude', '从 Claude 原生历史导入', '读取 ~/.claude/projects/ 下的会话文件，恢复对话文本与工具调用，并保留 Claude 侧续接上下文。')}
          <div class="modal-loading">正在加载…</div>
        </div>
      </div>
    `;

    document.body.appendChild(overlay);

    function close() {
      overlay.remove();
      _onNativeSessions = null;
    }

    overlay.querySelector('#is-close-btn').addEventListener('click', close);
    overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });

    function renderNativeSessions() {
      const body = overlay.querySelector('#is-body');
      if (!body) return;
      if (!nativeGroups || nativeGroups.length === 0) {
        body.innerHTML = `${buildAgentContextCard('claude', '从 Claude 原生历史导入', '读取 ~/.claude/projects/ 下的会话文件，恢复对话文本与工具调用，并保留 Claude 侧续接上下文。')}<div class="modal-empty">未找到本地 CLI 会话</div>`;
        return;
      }
      body.innerHTML = buildAgentContextCard('claude', '从 Claude 原生历史导入', '读取 ~/.claude/projects/ 下的会话文件，恢复对话文本与工具调用，并保留 Claude 侧续接上下文。');
      const hiddenCount = nativeGroups.reduce((sum, group) => (
        sum + (Array.isArray(group.sessions) ? group.sessions.filter((sess) => sess.alreadyImported).length : 0)
      ), 0);
      appendImportVisibilityToggle(body, {
        hiddenCount,
        showImported,
        onToggle: (next) => {
          showImported = next;
          renderNativeSessions();
        },
      });

      const visibleGroups = nativeGroups
        .map((group) => ({
          ...group,
          sessions: showImported
            ? (group.sessions || [])
            : (group.sessions || []).filter((sess) => !sess.alreadyImported),
        }))
        .filter((group) => group.sessions.length > 0);
      if (visibleGroups.length === 0) {
        body.insertAdjacentHTML('beforeend', `<div class="modal-empty">已隐藏 ${hiddenCount} 个已导入会话，打开上方开关可查看。</div>`);
        return;
      }

      for (const group of visibleGroups) {
        const groupEl = document.createElement('div');
        groupEl.className = 'import-group';
        // Convert slug dir to readable path
        let readablePath = group.dir.replace(/-/g, '/');
        if (!readablePath.startsWith('/')) readablePath = '/' + readablePath;
        readablePath = readablePath.replace(/\/+/g, '/');
        const groupTitle = document.createElement('div');
        groupTitle.className = 'import-group-title';
        groupTitle.textContent = readablePath;
        groupEl.appendChild(groupTitle);
        for (const sess of group.sessions) {
          const item = document.createElement('div');
          item.className = 'import-item';
          const info = document.createElement('div');
          info.className = 'import-item-info';
          const titleEl = document.createElement('div');
          titleEl.className = 'import-item-title';
          titleEl.textContent = sess.title;
          const meta = document.createElement('div');
          meta.className = 'import-item-meta';
          const cwdText = sess.cwd ? sess.cwd : '';
          const timeText = sess.updatedAt ? timeAgo(sess.updatedAt) : '';
          meta.textContent = [cwdText, timeText].filter(Boolean).join(' · ');
          info.appendChild(titleEl);
          info.appendChild(meta);
          const btn = document.createElement('button');
          btn.className = 'import-item-btn';
          btn.textContent = sess.alreadyImported ? '重新导入' : '导入';
          btn.addEventListener('click', () => {
            if (sess.alreadyImported) {
              if (!confirm('已导入过此会话，重新导入将覆盖已有内容。确认继续？')) return;
            } else {
              if (!confirm('由于 cc-web 与本地 CLI 的逻辑不同，导入会话需要解析后方可展示，导入后将覆盖已有内容。确认继续？')) return;
            }
            close();
            send({ type: 'import_native_session', sessionId: sess.sessionId, projectDir: group.dir });
          });
          item.appendChild(info);
          item.appendChild(btn);
          groupEl.appendChild(item);
        }
        body.appendChild(groupEl);
      }
    }

    _onNativeSessions = (groups) => {
      nativeGroups = Array.isArray(groups) ? groups : [];
      renderNativeSessions();
    };

    send({ type: 'list_native_sessions' });
  }

  function showImportCodexSessionModal() {
    if (!isCodexLikeAgent(currentAgent)) return;
    const importAgent = currentAgent;
    let codexItems = [];
    let showImported = false;
    const label = AGENT_LABELS[importAgent] || 'Codex';
    const contextTitle = importAgent === 'codexapp'
      ? '从 Codex App rollout 历史导入'
      : '从 Codex rollout 历史导入';
    const contextCopy = importAgent === 'codexapp'
      ? '读取 ~/.codex/sessions/ 下的 rollout 文件，恢复对话文本、工具调用和 token 统计，并绑定 Codex App 线程用于后续续接。'
      : '读取 ~/.codex/sessions/ 下的 rollout 文件，恢复用户消息、助手输出、函数调用和 token 统计。';
    const overlay = document.createElement('div');
    overlay.className = 'modal-overlay';
    overlay.id = 'import-codex-session-overlay';

    overlay.innerHTML = `
      <div class="modal-panel modal-panel-wide">
        <div class="modal-header">
          <span class="modal-title">导入本地 ${escapeHtml(label)} 会话</span>
          <button class="modal-close-btn" id="ics-close-btn">✕</button>
        </div>
        <div class="modal-body" id="ics-body">
          ${buildAgentContextCard(importAgent, contextTitle, contextCopy)}
          <div class="modal-loading">正在加载 ${escapeHtml(label)} 本地历史…</div>
        </div>
      </div>
    `;

    document.body.appendChild(overlay);

    function close() {
      overlay.remove();
      _onCodexSessions = null;
    }

    overlay.querySelector('#ics-close-btn').addEventListener('click', close);
    overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });

    function renderCodexSessions() {
      const body = overlay.querySelector('#ics-body');
      if (!body) return;
      if (!codexItems || codexItems.length === 0) {
        body.innerHTML = `${buildAgentContextCard(importAgent, contextTitle, contextCopy)}<div class="modal-empty">未找到本地 ${escapeHtml(label)} 会话</div>`;
        return;
      }

      body.innerHTML = buildAgentContextCard(importAgent, contextTitle, contextCopy);
      const hiddenCount = codexItems.filter((sess) => sess.alreadyImported).length;
      appendImportVisibilityToggle(body, {
        hiddenCount,
        showImported,
        onToggle: (next) => {
          showImported = next;
          renderCodexSessions();
        },
      });

      const visibleItems = showImported ? codexItems : codexItems.filter((sess) => !sess.alreadyImported);
      if (visibleItems.length === 0) {
        body.insertAdjacentHTML('beforeend', `<div class="modal-empty">已隐藏 ${hiddenCount} 个已导入会话，打开上方开关可查看。</div>`);
        return;
      }

      visibleItems.forEach((sess) => {
        const item = document.createElement('div');
        item.className = 'import-item';

        const info = document.createElement('div');
        info.className = 'import-item-info';

        const titleEl = document.createElement('div');
        titleEl.className = 'import-item-title';
        titleEl.textContent = sess.title || sess.threadId;

        const meta = document.createElement('div');
        meta.className = 'import-item-meta';
        meta.textContent = [
          sess.cwd || '',
          sess.source ? `source:${sess.source}` : '',
          sess.updatedAt ? timeAgo(sess.updatedAt) : '',
        ].filter(Boolean).join(' · ');

        const tags = document.createElement('div');
        tags.className = 'import-item-tags';
        if (sess.cliVersion) {
          const ver = document.createElement('span');
          ver.className = 'import-item-tag';
          ver.textContent = `CLI ${sess.cliVersion}`;
          tags.appendChild(ver);
        }
        if (sess.source) {
          const source = document.createElement('span');
          source.className = 'import-item-tag';
          source.textContent = sess.source;
          tags.appendChild(source);
        }
        if ((sess.duplicateCount || 0) > 1) {
          const merged = document.createElement('span');
          merged.className = 'import-item-tag';
          merged.textContent = `合并 ${sess.duplicateCount} 条`;
          tags.appendChild(merged);
        }

        info.appendChild(titleEl);
        info.appendChild(meta);
        if (tags.children.length > 0) info.appendChild(tags);

        const btn = document.createElement('button');
        btn.className = 'import-item-btn';
        btn.textContent = sess.alreadyImported ? '重新导入' : '导入';
        btn.addEventListener('click', () => {
          const confirmed = sess.alreadyImported
            ? confirm(`已导入过此 ${label} 会话，重新导入将覆盖已有内容。确认继续？`)
            : confirm(`将解析本地 ${label} rollout 历史并导入当前 Web 视图。确认继续？`);
          if (!confirmed) return;
          close();
          send({ type: 'import_codex_session', agent: importAgent, threadId: sess.threadId, rolloutPath: sess.rolloutPath });
        });

        item.appendChild(info);
        item.appendChild(btn);
        body.appendChild(item);
      });
    }

    _onCodexSessions = (items) => {
      codexItems = Array.isArray(items) ? items : [];
      renderCodexSessions();
    };

    send({ type: 'list_codex_sessions', agent: importAgent });
  }

  // --- Helpers ---
  function escapeHtml(str) {
    if (!str) return '';
    return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
  }

  function timeAgo(dateStr) {
    if (!dateStr) return '';
    const diff = Date.now() - new Date(dateStr).getTime();
    const mins = Math.floor(diff / 60000);
    if (mins < 1) return '刚刚';
    if (mins < 60) return `${mins}分钟前`;
    const hours = Math.floor(mins / 60);
    if (hours < 24) return `${hours}小时前`;
    const days = Math.floor(hours / 24);
    if (days < 30) return `${days}天前`;
    return new Date(dateStr).toLocaleDateString('zh-CN');
  }

  // --- Init ---
  applyTheme(currentTheme);
  setCurrentAgent(currentAgent);
  updateNoteModeUI();
  renderSessionList();
  connect();
  window.addEventListener('resize', updateCwdBadge);
  window.addEventListener('online', () => {
    reconnectAttempts = 0;
    connect();
  });
  window.addEventListener('offline', clearReconnectTimer);
  window.addEventListener('pagehide', () => {
    isPageUnloading = true;
    clearReconnectTimer();
    if (ws && ws.readyState <= 1) {
      ws.close(1001, 'pagehide');
    }
  });
  window.addEventListener('pageshow', () => {
    isPageUnloading = false;
    if (!ws || ws.readyState > 1) {
      reconnectAttempts = 0;
      connect();
    }
  });

  // Register Service Worker for mobile push notifications
  if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register(`/sw.js?v=${ASSET_VERSION}`).catch(() => {});
  }

  // Restore remembered password
  const savedPw = localStorage.getItem('cc-web-pw');
  if (savedPw) {
    loginPassword.value = savedPw;
    rememberPw.checked = true;
  }

  // 页签切回只做轻量状态同步：不要因为 visible 事件整屏重载当前会话，
  // 否则 renderMessages() 会重建气泡并把滚动条拉回底部。
  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState !== 'visible') return;
    if (!ws || ws.readyState > 1) {
      // WS is dead, force reconnect
      reconnectAttempts = 0;
      connect();
    } else if (ws.readyState === 1 && wsAuthenticated) {
      if (currentSessionId && !activeSessionLoad && (isGenerating || currentSessionRunning)) {
        requestSessionResume(currentSessionId, {
          requestId: createSessionSwitchRequestId(currentSessionId),
        });
      }
      send({ type: 'list_sessions' });
    }
  });

  if (!authToken) {
    loginOverlay.hidden = false;
    app.hidden = true;
  } else {
    loginOverlay.hidden = true;
    app.hidden = false;
  }
})();
