// === CC-Web Frontend ===
(function () {
'use strict';
const ASSET_VERSION = '20260621-cross-reply-collapse-last-section-offset';
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 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_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}`,
'.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 DEFAULT_AGENT = 'claude';
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_COLLAPSE_VISIBLE_LIMIT = 5;
const OLD_SESSION_COLLAPSE_DAYS = 7;
const OLD_SESSION_COLLAPSE_MS = OLD_SESSION_COLLAPSE_DAYS * 24 * 60 * 60 * 1000;
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 = 'opus';
let currentAgent = AGENT_LABELS[localStorage.getItem('cc-web-agent')] ? localStorage.getItem('cc-web-agent') : 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 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 currentSessionRunning = false;
let fileBrowserState = null;
let directoryPickerState = null;
let codexAppUserInputModal = null;
let codexAppApprovalModal = null;
let pendingNewSessionRequest = null;
let pendingSessionSwitchRequest = null;
let skipDeleteConfirm = localStorage.getItem('cc-web-skip-delete-confirm') === '1';
let pendingInitialSessionLoad = false;
let noteMode = false;
let noteDraftSeq = 0;
let isReloadingMcp = false;
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 userMessageIndex = new Map();
const expandedOldSessionAgents = 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 chatRuntimeState = $('#chat-runtime-state');
const chatCwd = $('#chat-cwd');
const userOutlineBtn = $('#user-outline-btn');
const userOutlinePanel = $('#user-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 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 `
✿
欢迎使用 CC-Web
开始与 ${label} 对话
`;
}
function normalizeAgent(agent) {
return AGENT_LABELS[agent] ? agent : DEFAULT_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 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 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 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 (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, sendNoteBtn);
bubble.append(meta, text, actions);
div.append(avatar, bubble);
return div;
}
function renderPendingNotes(options = {}) {
if (!pendingNotesTray) return;
pendingNotesTray.innerHTML = '';
const notes = getCurrentNotes(false);
if (!notes || notes.length === 0) {
pendingNotesTray.hidden = true;
if (options.updateScrollbar !== false) updateScrollbar();
return;
}
const frag = document.createDocumentFragment();
notes.forEach((note) => frag.appendChild(createPendingNoteElement(note)));
pendingNotesTray.appendChild(frag);
pendingNotesTray.hidden = false;
if (options.scrollIntoView !== false && options.scroll !== false) {
pendingNotesTray.scrollTop = pendingNotesTray.scrollHeight;
}
if (options.updateScrollbar !== false) updateScrollbar();
}
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 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 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 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 ? '界面主题
' : ''}
${THEME_OPTIONS.map((theme) => `
`).join('')}
`;
}
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 `
外观
`;
}
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 `
通知
`;
}
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 = `
`;
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 = `
${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 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 = '暂无用户消息
';
userOutlineBtn.disabled = true;
} else {
userOutlinePanel.innerHTML = items.map((item, index) => `
`).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();
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 = `
`;
button.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
const bubble = button.closest('.msg-bubble');
scrollAssistantBubbleToLastSection(bubble);
});
return button;
}
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;
bubble.appendChild(button);
}
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 || '';
return {
sessionId,
id: sessionId,
messages: cloneMessages(payload.messages || []),
title: payload.title || '新会话',
mode: payload.mode || 'yolo',
model: payload.model || '',
agent: normalizeAgent(payload.agent),
pinnedAt: payload.pinnedAt || 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) : [],
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 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;
}
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');
}
async function reloadCurrentMcpServers() {
if (!currentSessionId || !isCodexAppAgent(currentAgent) || isReloadingMcp) return;
isReloadingMcp = true;
updateReloadMcpButtonUI();
try {
await fetchAuthJson(`/api/sessions/${encodeURIComponent(currentSessionId)}/reload-mcp`, {
method: 'POST',
});
showToast('已请求重载 MCP');
} 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 = `
${msg.summary ? `
${escapeHtml(msg.summary)}
` : ''}
${msg.reason ? `
${escapeHtml(msg.reason)}
` : ''}
${escapeHtml(msg.approvalType || 'request')}
${msg.itemId ? `${escapeHtml(msg.itemId)}` : ''}
${payloadText ? `
${escapeHtml(payloadText)}` : ''}
`;
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 `
`;
}).join('');
const otherHtml = hasOther ? `
` : '';
return `
`;
}
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 = `
`;
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 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 = '这个目录里没有可进入的子目录,直接使用当前目录也可以。
';
return;
}
directoryPickerState.listEl.innerHTML = safeEntries.map((entry) => {
const metaParts = [entry.symlink ? '链接目录' : '目录'];
if (entry.updatedAt) metaParts.push(timeAgo(entry.updatedAt));
return `
`;
}).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 = '正在读取目录…
';
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 = `${escapeHtml(err.message || '目录读取失败')}
`;
setDirectoryPickerStatus(err.message || '目录读取失败', 'error');
}
}
function showDirectoryPicker(options = {}) {
closeDirectoryPicker();
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.id = 'directory-picker-overlay';
overlay.innerHTML = `
`;
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 = '这个目录里还没有可显示的文件
';
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 `
`;
}).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 = '正在读取目录…
';
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 = `${escapeHtml(err.message || '目录读取失败')}
`;
setFileBrowserStatus(err.message || '目录读取失败', 'error');
}
}
async function openFileBrowserFile(targetPath) {
if (!fileBrowserState) return;
const state = fileBrowserState;
const normalizedPath = normalizeBrowserPath(targetPath);
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)}`);
state.previewMetaEl.textContent = metaParts.join(' · ');
state.previewEmptyEl.hidden = true;
state.previewCodeEl.hidden = false;
state.previewCodeEl.textContent = data.content || '';
setFileBrowserStatus(`已打开 ${data.name || '文件'}`);
} 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 = `
`;
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 = `
正在加载图片…
`;
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 `
`;
}).join('');
return `${items}
`;
}
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) => `
${escapeHtml(attachment.filename || 'image')}
上传中 · ${formatFileSize(attachment.size)}
`).join('');
const readyHtml = pendingAttachments.map((attachment, index) => `
${escapeHtml(attachment.filename || 'image')}
${formatFileSize(attachment.size)} · 将随下一条消息发送
`).join('');
const noteHtml = [
uploadingAttachments.length > 0
? '图片上传中,此时发送不会包含尚未完成的图片。
'
: '',
].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) => normalizeAgent(s.agent) === currentAgent);
}
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 splitCollapsedOldSessions(regularSessions, pinnedCount) {
if (expandedOldSessionAgents.has(currentAgent)) {
return { visibleRegularSessions: regularSessions, hiddenOldSessions: [] };
}
const totalCount = pinnedCount + regularSessions.length;
if (totalCount <= OLD_SESSION_COLLAPSE_VISIBLE_LIMIT) {
return { visibleRegularSessions: regularSessions, hiddenOldSessions: [] };
}
const nowMs = Date.now();
const visibleRegularLimit = Math.max(0, OLD_SESSION_COLLAPSE_VISIBLE_LIMIT - pinnedCount);
const visibleRegularSessions = [];
const hiddenOldSessions = [];
regularSessions.forEach((session, index) => {
const shouldKeepVisible = session.id === currentSessionId || session.isRunning || session.hasUnread || session.waitingOnChildren;
const canCollapse = index >= visibleRegularLimit && isOlderThanOldSessionWindow(session, nowMs);
if (canCollapse && !shouldKeepVisible) {
hiddenOldSessions.push(session);
} else {
visibleRegularSessions.push(session);
}
});
return { visibleRegularSessions, hiddenOldSessions };
}
function createOldSessionLoadMoreButton(hiddenCount) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'session-list-load-more';
button.setAttribute('aria-label', `加载更多 ${hiddenCount} 条 7 天前会话`);
button.innerHTML = `
加载更多
${hiddenCount} 条 7 天前会话
`;
button.addEventListener('click', () => {
expandedOldSessionAgents.add(currentAgent);
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 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' : ''}${waitingOnChildren ? ' waiting-children' : ''}`;
item.dataset.id = session.id;
const sessionCwd = getSessionEffectiveCwd(session);
if (sessionCwd) item.title = sessionCwd;
item.innerHTML = `
${escapeHtml(session.title || 'Untitled')}
${isPinned ? '顶' : ''}
${session.isRunning ? '运行中' : ''}
${!session.isRunning && waitingOnChildren ? `${escapeHtml(waitingLabel)}` : ''}
${session.hasUnread ? '' : ''}
${timeAgo(session.updated)}
`;
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);
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;
return {
waitingOnChildren: !!(meta?.waitingOnChildren || cached?.waitingOnChildren),
pendingReplyCount: Number(meta?.pendingReplyCount ?? cached?.pendingReplyCount ?? 0),
readyReplyCount: Number(meta?.readyReplyCount ?? cached?.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();
}
function updateAgentScopedUI() {
if (chatAgentBtn) {
chatAgentBtn.textContent = AGENT_LABELS[currentAgent];
chatAgentBtn.setAttribute('aria-expanded', chatAgentMenu && !chatAgentMenu.hidden ? 'true' : '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) {
if (isCodexAppAgent(currentAgent)) {
importSessionBtn.textContent = 'Codex App 暂不支持导入';
importSessionBtn.disabled = true;
} else {
importSessionBtn.textContent = currentAgent === 'codex' ? '导入本地 Codex 会话' : '导入本地 Claude 会话';
importSessionBtn.disabled = false;
}
}
updateReloadMcpButtonUI();
}
function setCurrentAgent(agent) {
currentAgent = normalizeAgent(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;
const willOpen = chatAgentMenu.hidden;
chatAgentMenu.hidden = !willOpen;
chatAgentBtn.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
}
function resetChatView(agent) {
setCurrentAgent(agent);
closeUserOutlinePanel();
closeFileBrowser();
currentSessionId = null;
loadedHistorySessionId = null;
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;
const snapshotAgent = normalizeAgent(snapshot.agent);
if (fileBrowserState && (fileBrowserState.sessionId !== snapshot.sessionId || (snapshot.cwd && fileBrowserState.rootPath !== snapshot.cwd))) {
closeFileBrowser();
}
const preserveStreaming = !!(options.preserveStreaming && isGenerating && snapshot.sessionId === currentSessionId && snapshot.isRunning);
if (isGenerating && !preserveStreaming) {
isGenerating = false;
generatingSessionId = null;
updateGenerationControls();
pendingText = '';
window.pendingContentBlocks = [];
activeToolCalls.clear();
activeTodoCallTargets.clear();
}
currentSessionId = snapshot.sessionId;
loadedHistorySessionId = snapshot.sessionId;
setLastSessionForAgent(snapshot.agent, currentSessionId);
chatTitle.textContent = snapshot.title || '新会话';
updateSessionIdBadge();
setCurrentAgent(snapshotAgent);
migratePendingNotesToSession(snapshot.sessionId, snapshotAgent);
setCurrentSessionRunningState(snapshot.isRunning);
setStatsDisplay(snapshot);
closeUserOutlinePanel();
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 });
} else {
generatingSessionId = snapshot.sessionId;
}
highlightActiveSession();
renderSessionList();
if (!options.skipCloseSidebar) closeSidebar();
if (snapshot.hasUnread && !options.suppressUnreadToast) {
showToast('后台任务已完成', snapshot.sessionId);
}
}
function syncViewForAgent(agent, options = {}) {
const targetAgent = normalizeAgent(agent);
const { preserveCurrent = true, loadLast = true } = options;
setCurrentAgent(targetAgent);
closeUserOutlinePanel();
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 setSessionLoading(sessionId, options = {}) {
const loading = !!sessionId;
const blocking = options.blocking !== false;
activeSessionLoad = loading ? { sessionId, blocking, snapshot: null } : null;
const showOverlay = !!(loading && blocking);
document.body.classList.toggle('session-loading-active', showOverlay);
sessionLoadingOverlay.hidden = !showOverlay;
sessionLoadingOverlay.setAttribute('aria-hidden', showOverlay ? 'false' : 'true');
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 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) 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 || '',
};
if (ws && ws.readyState === 1 && wsAuthenticated) {
flushPendingSessionSwitch();
return;
}
if (!ws || ws.readyState > 1) connect();
}
function flushPendingSessionSwitch() {
if (!pendingSessionSwitchRequest) return;
if (!ws || ws.readyState !== 1 || !wsAuthenticated) return;
const request = pendingSessionSwitchRequest;
pendingSessionSwitchRequest = null;
if (!activeSessionLoad) {
setSessionLoading(request.sessionId, {
blocking: request.blocking,
label: request.label || undefined,
});
}
ws.send(JSON.stringify({ type: 'load_session', sessionId: request.sessionId }));
}
function showCachedSession(sessionId) {
const snapshot = buildCachedSessionSnapshot(sessionId);
if (!snapshot) return false;
if (currentSessionId && currentSessionId !== sessionId) {
send({ type: 'detach_view' });
}
closeUserOutlinePanel();
clearSessionLoading();
touchSessionCache(sessionId);
applySessionSnapshot(snapshot, { immediate: true, suppressUnreadToast: true });
return true;
}
function openSession(sessionId, options = {}) {
if (!sessionId) return;
closeUserOutlinePanel();
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 _previewCodeMap = new Map();
let _previewCodeId = 0;
const renderer = new marked.Renderer();
renderer.code = function (code, language) {
const lang = (language || 'plaintext').toLowerCase();
let highlighted;
try {
if (hljs.getLanguage(lang)) {
highlighted = hljs.highlight(code, { language: lang }).value;
} else {
highlighted = hljs.highlightAuto(code).value;
}
} catch {
highlighted = escapeHtml(code);
}
const canPreview = PREVIEW_LANGS.has(lang);
const previewBtn = canPreview
? ``
: '';
const previewPane = canPreview
? ``
: '';
const cid = canPreview ? (++_previewCodeId) : 0;
if (canPreview) _previewCodeMap.set(cid, code);
return `
${previewPane}
${highlighted}
`;
};
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 cid = wrapper.dataset.cid ? Number(wrapper.dataset.cid) : 0;
const code = (cid && _previewCodeMap.has(cid)) ? _previewCodeMap.get(cid) : wrapper.querySelector('code')?.textContent;
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';
}
};
// --- 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; }
handleServerMessage(msg);
};
socket.onclose = () => {
if (ws !== socket) return;
ws = null;
wsAuthenticated = false;
if (activeSessionLoad?.sessionId && !isPageUnloading) {
pendingSessionSwitchRequest = {
sessionId: activeSessionLoad.sessionId,
blocking: activeSessionLoad.blocking,
label: sessionLoadingLabel?.textContent || '',
};
}
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) {
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;
flushPendingSessionSwitch();
send({ type: 'get_codex_config' });
// Check if must change password
if (msg.mustChangePassword) {
showForceChangePassword();
} else {
pendingInitialSessionLoad = true;
}
} else {
pendingSessionSwitchRequest = null;
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;
syncViewForAgent(currentAgent, { preserveCurrent: false, loadLast: true });
} else if (currentSessionId && !getSessionMeta(currentSessionId)) {
resetChatView(currentAgent);
}
break;
case 'session_info':
if (pendingNewSessionRequest) pendingNewSessionRequest = null;
const snapshot = normalizeSessionSnapshot(msg);
sessions = sessions.map((session) => (
session.id === snapshot.sessionId
? {
...session,
cwd: snapshot.cwd || session.cwd || '',
projectName: snapshot.cwd ? getPathLeaf(snapshot.cwd) : session.projectName || '',
title: snapshot.title || session.title,
pinnedAt: snapshot.pinnedAt || session.pinnedAt || null,
isRunning: snapshot.isRunning,
waitingOnChildren: snapshot.waitingOnChildren,
pendingReplyCount: snapshot.pendingReplyCount,
readyReplyCount: snapshot.readyReplyCount,
waitingReplyCount: snapshot.waitingReplyCount,
failedReplyCount: snapshot.failedReplyCount,
}
: session
));
if (activeSessionLoad?.sessionId === msg.sessionId) {
activeSessionLoad.snapshot = snapshot;
}
applySessionSnapshot(snapshot, {
immediate: isBlockingSessionLoad(msg.sessionId),
suppressUnreadToast: false,
preserveStreaming: msg.sessionId === currentSessionId && msg.isRunning,
});
if (msg.sessionId === currentSessionId) {
setCurrentSessionRunningState(!!msg.isRunning);
}
if (!msg.historyPending) {
if (activeSessionLoad?.sessionId === msg.sessionId) {
finalizeLoadedSession(msg.sessionId);
} else {
cacheSessionSnapshot(snapshot);
finishSessionSwitch(msg.sessionId);
}
}
break;
case 'session_history_chunk':
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,
});
if (!msg.remaining) {
finalizeLoadedSession(msg.sessionId);
}
}
break;
case 'session_message':
if (msg.sessionId && msg.message) {
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 (msg.message.crossConversation?.replyToRequestId) {
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 (msg.sessionId === currentSessionId && msg.message) {
collectClosedCollabAgentIds([msg.message]).forEach((id) => closedCollabAgentIds.add(id));
const welcome = messagesDiv.querySelector('.welcome-msg');
if (welcome) welcome.remove();
messagesDiv.appendChild(buildMsgElement(msg.message));
scrollToBottom();
setCurrentSessionRunningState(!!getSessionMeta(currentSessionId)?.isRunning);
}
renderSessionList();
break;
case 'session_renamed':
sessions = sessions.map((session) => session.id === msg.sessionId ? { ...session, title: msg.title } : session);
updateCachedSession(msg.sessionId, (snapshot) => { snapshot.title = msg.title; });
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;
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 '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;
// Server has an active process for this session — resume streaming
setCurrentSessionRunningState(true);
if (!isGenerating || !document.getElementById('streaming-msg')) {
startGenerating(msg.sessionId || currentSessionId);
} 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 'error':
if (!isCurrentSessionEvent(msg)) break;
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,
createCwd: true,
});
},
onCancel: () => {
showNewSessionModal({
agent: request.agent,
cwd: request.rawCwd || request.cwd,
mode: request.mode,
});
},
});
clearSessionLoading();
if (!isGenerating && currentSessionId) {
setCurrentSessionRunningState(!!getSessionMeta(currentSessionId)?.isRunning);
}
break;
}
if (pendingNewSessionRequest) pendingNewSessionRequest = null;
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) {
// Reload current session to show completed response
openSession(msg.sessionId, { forceSync: true, blocking: false });
} 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) {
const targetSessionId = sessionId || currentSessionId || null;
if (targetSessionId && currentSessionId && targetSessionId !== currentSessionId) return false;
isGenerating = true;
generatingSessionId = targetSessionId;
setCurrentSessionRunningState(true);
pendingText = '';
window.pendingContentBlocks = [];
activeToolCalls.clear();
activeTodoCallTargets.clear();
toolGroupCount = 0;
hasGrouped = false;
updateNoteModeUI();
// 不禁用输入框,允许用户继续输入(但无法发送)
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 = '
';
const toolsDiv = document.createElement('div');
toolsDiv.className = 'msg-tools';
bubble.appendChild(textDiv);
bubble.appendChild(toolsDiv);
syncAssistantLastSectionButton(msgEl);
messagesDiv.appendChild(msgEl);
scrollToBottom();
return true;
}
function finishGenerating(sessionId) {
if (sessionId && currentSessionId && sessionId !== currentSessionId) return;
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');
syncAssistantLastSectionButton(streamEl);
}
if (sessionId) {
migratePendingNotesToSession(sessionId, currentAgent);
}
pendingText = '';
activeToolCalls.clear();
activeTodoCallTargets.clear();
toolGroupCount = 0;
hasGrouped = false;
renderPendingNotes();
}
// --- 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;
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; }
textDiv.innerHTML = renderMarkdown(pendingText);
}
syncAssistantLastSectionButton(streamEl);
scrollToBottom();
}
function renderMarkdown(text) {
if (!text) return '
';
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, '&')
.replace(/"/g, '"')
.replace(//g, '>');
}
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 = `
`;
} else {
avatar.innerHTML = `
`;
}
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 = `
`;
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');
textDiv.innerHTML = renderMarkdown(before);
bubble.appendChild(textDiv);
}
bubble.appendChild(createTodoListElement(parsed));
if (after.trim()) {
const textDiv = document.createElement('div');
textDiv.innerHTML = renderMarkdown(after);
bubble.appendChild(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) {}
}
bubble.innerHTML = renderMarkdown(content);
return;
}
if (Array.isArray(content)) {
content.forEach(block => {
if (block.type === 'text') {
const textDiv = document.createElement('div');
textDiv.innerHTML = renderMarkdown(block.text || '');
bubble.appendChild(textDiv);
} else if (block.type === 'todo_list') {
bubble.appendChild(createTodoListElement(block));
}
});
return;
}
bubble.innerHTML = renderMarkdown(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) {
const el = createMsgElement(m.role, m.content, m.attachments || [], m);
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);
}
}
}
}
return el;
}
function renderMessages(messages, options = {}) {
renderEpoch++;
const epoch = renderEpoch;
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) => frag.appendChild(buildMsgElement(message)));
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]));
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]));
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;
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) => frag.appendChild(buildMsgElement(m)));
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) : '暂无推理内容
';
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;
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);
scrollToBottom();
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);
scrollToBottom();
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);
scrollToBottom();
}
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 ? `${escapeHtml(options.title)}
` : '';
const confirmText = options.confirmText || '确认';
const cancelText = options.cancelText || '取消';
box.innerHTML = `
${title}
${escapeHtml(options.message || '')}
`;
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 = `
${escapeHtml(getDeleteConfirmMessage(agent))}
`;
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;
}
// --- 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 { visibleRegularSessions, hiddenOldSessions } = isSearchingSessions
? { visibleRegularSessions: regularSessions, hiddenOldSessions: [] }
: splitCollapsedOldSessions(regularSessions, pinnedSessions.length);
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 = `
置顶
`;
pinnedGroupEl.appendChild(pinnedHeader);
for (const session of pinnedSessions) {
pinnedGroupEl.appendChild(createSessionListItem(session));
}
sessionList.appendChild(pinnedGroupEl);
}
const { groups: projectGroups, ungroupedSessions } = groupSessionsByProject(visibleRegularSessions);
projectGroups.forEach((group, groupIndex) => {
const groupKey = getProjectCollapseKey(group);
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 = `
`;
groupEl.appendChild(header);
const groupBody = document.createElement('div');
groupBody.id = groupBodyId;
groupBody.className = 'session-project-sessions';
groupBody.hidden = isCollapsed;
for (const s of group.sessions) {
groupBody.appendChild(createSessionListItem(s));
}
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);
});
for (const s of ungroupedSessions) {
sessionList.appendChild(createSessionListItem(s));
}
if (hiddenOldSessions.length > 0) {
sessionList.appendChild(createOldSessionLoadMoreButton(hiddenOldSessions.length));
}
}
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 `
${kindLabel}
${escapeHtml(item.label || item.name || item.insertion || '')}
${escapeHtml(item.description || item.title || '')}
`;
}).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 = `
${escapeHtml(title)}
${options.map(opt => `
${escapeHtml(opt.label)}
${escapeHtml(opt.desc)}
${opt.value === currentValue ? '
✓' : ''}
`).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);
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);
}
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;
}
send({ type: 'message', text, sessionId: currentSessionId, mode: currentMode, agent: currentAgent });
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 (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 (isCodexAppAgent(currentAgent)) {
appendError('Codex App 模式暂不支持导入本地会话。');
} else if (currentAgent === 'codex') {
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();
}
});
sendBtn.addEventListener('click', sendMessage);
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}」任务完成`,
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 `
`;
}
if (provider === 'telegram') {
return `
`;
}
if (provider === 'serverchan') {
return `
`;
}
if (provider === 'feishu') {
return `
`;
}
if (provider === 'qqbot') {
return `
`;
}
return '';
}
function buildAgentContextCard(agent, title, copy) {
const label = AGENT_LABELS[normalizeAgent(agent)] || AGENT_LABELS.claude;
return `
${escapeHtml(label)} Space
${escapeHtml(title)}
${escapeHtml(copy)}
`;
}
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 `
通知摘要
`;
}
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 = `
`;
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 = `
⚙ Codex 设置
Codex 运行配置
${buildAppearanceSettingsHtml()}
${buildNotifyEntryHtml(null)}
系统
`;
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 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 _onUpdateInfo = null;
function showCodexStatus(msg, type) {
codexStatus.textContent = msg;
codexStatus.className = 'settings-status ' + (type || '');
}
function renderCodexProfileArea() {
const mode = codexModeSelect.value;
if (mode === 'local') {
codexProfileArea.innerHTML = `
当前将直接复用本机 codex 的登录态与 ~/.codex/config.toml。这适合你已经在终端里正常使用 Codex 的场景。
`;
return;
}
if (codexEditingProfiles.length === 0) {
codexProfileArea.innerHTML = `
自定义模式适合接 OpenAI 兼容服务,例如你提到的第三方 API 入口。这里仅覆盖 API Key 和 API Base URL,不会让配置页随意改模型 ID。
`;
panel.querySelector('#codex-profile-add-first').addEventListener('click', () => openCodexProfileModal());
return;
}
const options = codexEditingProfiles.map((profile) =>
``
).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 = `
自定义模式会为 cc-web 生成独立的 Codex 运行配置,只覆盖当前激活 Profile 的 API Key 与 API Base URL,不去碰你平时终端里用的全局登录态。
当前 Profile:${escapeHtml(currentProfile?.name || '未选择')}
API Base URL:${summaryBase}
`;
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 = `
这里不开放模型 ID 编辑。Codex 仍使用上方“默认模型”以及会话内的模型切换逻辑,只把 API 入口和密钥切换到当前 Profile。
`;
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 || '');
renderCodexProfileArea();
};
codexModeSelect.addEventListener('change', renderCodexProfileArea);
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,
};
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 = `有新版本 v${escapeHtml(info.latestVersion)}(当前 v${escapeHtml(info.localVersion)}) 查看更新`;
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() {
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 = `
⚙ Claude 设置
Claude 配置
${buildAppearanceSettingsHtml()}
${buildNotifyEntryHtml(null)}
系统
`;
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 = `⚠ 使用自定义模板会覆盖本地 API 配置,请提前做好备份。
`;
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 =>
``
).join('');
if (modelEditingTemplates.length === 0) {
modelCustomArea.innerHTML = `
尚无模板,点击下方按钮新建。
`;
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 = `
`;
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 = `
`;
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 => ``)
.join('');
const quickPickPaths = merged.slice(0, 6);
cwdPicks.innerHTML = quickPickPaths.map((pathValue) => `
`).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,
});
}
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 showImportSessionModal() {
if (currentAgent !== 'claude') return;
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.id = 'import-session-overlay';
overlay.innerHTML = `
${buildAgentContextCard('claude', '从 Claude 原生历史导入', '读取 ~/.claude/projects/ 下的会话文件,恢复对话文本与工具调用,并保留 Claude 侧续接上下文。')}
正在加载…
`;
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(); });
_onNativeSessions = (groups) => {
const body = overlay.querySelector('#is-body');
if (!body) return;
if (!groups || groups.length === 0) {
body.innerHTML = `${buildAgentContextCard('claude', '从 Claude 原生历史导入', '读取 ~/.claude/projects/ 下的会话文件,恢复对话文本与工具调用,并保留 Claude 侧续接上下文。')}未找到本地 CLI 会话
`;
return;
}
body.innerHTML = buildAgentContextCard('claude', '从 Claude 原生历史导入', '读取 ~/.claude/projects/ 下的会话文件,恢复对话文本与工具调用,并保留 Claude 侧续接上下文。');
for (const group of groups) {
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);
}
};
send({ type: 'list_native_sessions' });
}
function showImportCodexSessionModal() {
if (currentAgent !== 'codex') return;
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.id = 'import-codex-session-overlay';
overlay.innerHTML = `
${buildAgentContextCard('codex', '从 Codex rollout 历史导入', '读取 ~/.codex/sessions/ 下的 rollout 文件,恢复用户消息、助手输出、函数调用和 token 统计。')}
正在加载 Codex 本地历史…
`;
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(); });
_onCodexSessions = (items) => {
const body = overlay.querySelector('#ics-body');
if (!body) return;
if (!items || items.length === 0) {
body.innerHTML = `${buildAgentContextCard('codex', '从 Codex rollout 历史导入', '读取 ~/.codex/sessions/ 下的 rollout 文件,恢复用户消息、助手输出、函数调用和 token 统计。')}未找到本地 Codex 会话
`;
return;
}
body.innerHTML = buildAgentContextCard('codex', '从 Codex rollout 历史导入', '读取 ~/.codex/sessions/ 下的 rollout 文件,恢复用户消息、助手输出、函数调用和 token 统计。');
items.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);
}
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('已导入过此 Codex 会话,重新导入将覆盖已有内容。确认继续?')
: confirm('将解析本地 Codex rollout 历史并导入当前 Web 视图。确认继续?');
if (!confirmed) return;
close();
send({ type: 'import_codex_session', threadId: sess.threadId, rolloutPath: sess.rolloutPath });
});
item.appendChild(info);
item.appendChild(btn);
body.appendChild(item);
});
};
send({ type: 'list_codex_sessions' });
}
// --- Helpers ---
function escapeHtml(str) {
if (!str) return '';
return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
}
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;
}
// Visibility change: re-sync state when user returns to tab (critical for mobile)
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 && currentSessionId) {
// Preserve active streaming UI when returning to foreground.
if (isGenerating || currentSessionRunning) {
send({ type: 'load_session', sessionId: currentSessionId });
} else {
beginSessionSwitch(currentSessionId, { blocking: false, force: true });
}
}
});
if (!authToken) {
loginOverlay.hidden = false;
app.hidden = true;
} else {
loginOverlay.hidden = true;
app.hidden = false;
}
})();