feat: enhance codex app and cross-conversation messaging

This commit is contained in:
shiyue
2026-06-13 22:13:30 +08:00
parent 04e15c9c89
commit 4a1c988990
10 changed files with 3740 additions and 179 deletions

View File

@@ -2,8 +2,10 @@
(function () {
'use strict';
const ASSET_VERSION = '20260613-codexapp-tools2';
const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`;
const RENDER_DEBOUNCE = 100;
const COMPOSER_SUGGESTION_DEBOUNCE = 120;
const SLASH_COMMANDS = [
{ cmd: '/clear', desc: '清除当前会话' },
@@ -24,6 +26,7 @@
const AGENT_LABELS = {
claude: 'Claude',
codex: 'Codex',
codexapp: 'Codex App',
};
const DEFAULT_AGENT = 'claude';
@@ -99,6 +102,10 @@
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;
@@ -108,6 +115,7 @@
let currentSessionRunning = false;
let fileBrowserState = null;
let directoryPickerState = null;
let codexAppUserInputModal = null;
let pendingNewSessionRequest = null;
let skipDeleteConfirm = localStorage.getItem('cc-web-skip-delete-confirm') === '1';
let pendingInitialSessionLoad = false;
@@ -143,6 +151,7 @@
const chatCwd = $('#chat-cwd');
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');
@@ -172,6 +181,15 @@
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)}`;
}
@@ -218,9 +236,10 @@
}
if (inputWrapper) inputWrapper.classList.toggle('note-mode-active', active);
if (sendBtn) {
const allowRuntimeInsert = isGenerating && isCodexAppAgent(currentAgent) && !active;
sendBtn.classList.toggle('note-send', active);
sendBtn.title = active ? '记录笔记' : '发送';
sendBtn.hidden = isGenerating ? !active : false;
sendBtn.title = active ? '记录笔记' : (allowRuntimeInsert ? '插入' : '发送');
sendBtn.hidden = isGenerating ? (!active && !allowRuntimeInsert) : false;
}
if (msgInput) {
msgInput.placeholder = active ? '记录笔记,稍后从气泡发送…' : defaultMsgInputPlaceholder;
@@ -240,15 +259,15 @@
function createPendingNoteElement(note) {
const div = document.createElement('div');
div.className = 'msg note';
div.className = 'pending-note';
div.dataset.noteId = note.id;
const avatar = document.createElement('div');
avatar.className = 'msg-avatar note-avatar';
avatar.className = 'note-avatar';
avatar.textContent = 'N';
const bubble = document.createElement('div');
bubble.className = 'msg-bubble note-bubble';
bubble.className = 'note-bubble';
const meta = document.createElement('div');
meta.className = 'note-meta';
@@ -274,24 +293,23 @@
}
function renderPendingNotes(options = {}) {
messagesDiv.querySelectorAll('.msg.note').forEach((node) => node.remove());
if (!pendingNotesTray) return;
pendingNotesTray.innerHTML = '';
const notes = getCurrentNotes(false);
if (!notes || notes.length === 0) {
updateScrollbar();
pendingNotesTray.hidden = true;
if (options.updateScrollbar !== false) updateScrollbar();
return;
}
const welcome = messagesDiv.querySelector('.welcome-msg');
if (welcome) welcome.remove();
const frag = document.createDocumentFragment();
notes.forEach((note) => frag.appendChild(createPendingNoteElement(note)));
messagesDiv.appendChild(frag);
if (options.scroll !== false) {
scrollToBottom();
} else {
updateScrollbar();
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) {
@@ -336,7 +354,7 @@
function beginEditPendingNote(noteId) {
const found = findPendingNote(noteId);
if (!found) return;
const noteEl = messagesDiv.querySelector(`.msg.note[data-note-id="${noteId}"]`);
const noteEl = pendingNotesTray?.querySelector(`.pending-note[data-note-id="${noteId}"]`);
const bubble = noteEl?.querySelector('.note-bubble');
if (!bubble) return;
@@ -650,6 +668,15 @@
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) : '';
@@ -730,6 +757,7 @@
mode: payload.mode || 'yolo',
model: payload.model || '',
agent: normalizeAgent(payload.agent),
pinnedAt: payload.pinnedAt || null,
hasUnread: !!payload.hasUnread,
cwd: payload.cwd || null,
totalCost: typeof payload.totalCost === 'number' ? payload.totalCost : 0,
@@ -832,6 +860,7 @@
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;
}
return snapshot;
@@ -898,6 +927,154 @@
return data || {};
}
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',
sessionId,
requestId,
answers: {},
});
}
}
function cssEscape(value) {
if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(String(value || ''));
return String(value || '').replace(/["\\]/g, '\\$&');
}
function collectCodexAppUserInputAnswers(panel, questions) {
const answers = {};
for (const question of questions) {
const id = String(question?.id || '').trim();
if (!id) continue;
const escapedId = cssEscape(id);
const checked = panel.querySelector(`input[name="codex-ui-${escapedId}"]:checked`);
const values = [];
if (checked) {
if (checked.value === '__other__') {
const input = panel.querySelector(`[data-codex-ui-other="${escapedId}"]`);
const text = String(input?.value || '').trim();
if (text) values.push(text);
} else {
values.push(checked.value);
}
} else {
const input = panel.querySelector(`[data-codex-ui-other="${escapedId}"]`);
const text = String(input?.value || '').trim();
if (text) values.push(text);
}
answers[id] = { answers: values };
}
return answers;
}
function renderCodexAppQuestion(question, index) {
const id = String(question?.id || `q${index}`);
const options = Array.isArray(question?.options) ? question.options : [];
const hasOther = !!question?.isOther || options.length === 0;
const inputType = question?.isSecret ? 'password' : 'text';
const optionHtml = options.map((option, optionIndex) => {
const value = String(option?.label || `选项 ${optionIndex + 1}`);
return `
<label class="codex-user-input-option">
<input type="radio" name="codex-ui-${escapeHtml(id)}" value="${escapeHtml(value)}"${optionIndex === 0 && !hasOther ? ' checked' : ''}>
<span class="codex-user-input-option-copy">
<span class="codex-user-input-option-label">${escapeHtml(value)}</span>
${option?.description ? `<span class="codex-user-input-option-desc">${escapeHtml(option.description)}</span>` : ''}
</span>
</label>
`;
}).join('');
const otherHtml = hasOther ? `
<label class="codex-user-input-option codex-user-input-other-option">
${options.length > 0 ? `<input type="radio" name="codex-ui-${escapeHtml(id)}" value="__other__">` : ''}
<span class="codex-user-input-option-copy">
<span class="codex-user-input-option-label">${options.length > 0 ? '其他' : '回答'}</span>
<input class="codex-user-input-text" type="${inputType}" data-codex-ui-other="${escapeHtml(id)}" autocomplete="off">
</span>
</label>
` : '';
return `
<section class="codex-user-input-question">
<div class="codex-user-input-kicker">${escapeHtml(question?.header || `问题 ${index + 1}`)}</div>
<div class="codex-user-input-prompt">${escapeHtml(question?.question || '请选择一个答案。')}</div>
<div class="codex-user-input-options">
${optionHtml}
${otherHtml}
</div>
</section>
`;
}
function showCodexAppUserInputModal(msg) {
closeCodexAppUserInputModal(true);
const questions = Array.isArray(msg.questions) ? msg.questions : [];
const overlay = document.createElement('div');
overlay.className = 'modal-overlay codex-user-input-overlay';
overlay.innerHTML = `
<div class="modal-panel codex-user-input-panel">
<div class="modal-header">
<span class="modal-title">Codex App 需要输入</span>
<button class="modal-close-btn" type="button" data-codex-ui-cancel>✕</button>
</div>
<div class="modal-body codex-user-input-body">
${questions.length > 0
? questions.map((question, index) => renderCodexAppQuestion(question, index)).join('')
: '<div class="modal-empty">Codex App 没有提供可回答的问题。</div>'}
</div>
<div class="modal-footer">
<button class="modal-btn-secondary" type="button" data-codex-ui-cancel>取消</button>
<button class="modal-btn-primary" type="button" data-codex-ui-submit>提交</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const panel = overlay.querySelector('.codex-user-input-panel');
const escapeHandler = (e) => {
if (e.key === 'Escape') closeCodexAppUserInputModal(true);
};
document.addEventListener('keydown', escapeHandler);
codexAppUserInputModal = {
overlay,
requestId: msg.requestId || '',
sessionId: msg.sessionId || '',
escapeHandler,
};
overlay.querySelectorAll('[data-codex-ui-cancel]').forEach((button) => {
button.addEventListener('click', () => closeCodexAppUserInputModal(true));
});
overlay.addEventListener('click', (e) => {
if (e.target === overlay) closeCodexAppUserInputModal(true);
});
overlay.querySelector('[data-codex-ui-submit]')?.addEventListener('click', () => {
send({
type: 'codex_app_user_input_response',
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;
@@ -1824,26 +2001,66 @@
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 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 createSessionListItem(session) {
const item = document.createElement('div');
item.className = `session-item${session.id === currentSessionId ? ' active' : ''}`;
const isPinned = !!session.pinnedAt;
item.className = `session-item${session.id === currentSessionId ? ' active' : ''}${isPinned ? ' pinned' : ''}`;
item.dataset.id = session.id;
const sessionCwd = getSessionEffectiveCwd(session);
if (sessionCwd) item.title = sessionCwd;
item.innerHTML = `
<div class="session-item-main">
<span class="session-item-title">${escapeHtml(session.title || 'Untitled')}</span>
${isPinned ? '<span class="session-item-pin-badge" title="已置顶">顶</span>' : ''}
${session.isRunning ? '<span class="session-item-status">运行中</span>' : ''}
</div>
${session.hasUnread ? '<span class="session-unread-dot"></span>' : ''}
<span class="session-item-time">${timeAgo(session.updated)}</span>
<div class="session-item-actions">
<button class="session-item-btn pin${isPinned ? ' active' : ''}" title="${isPinned ? '取消置顶' : '置顶'}" aria-label="${isPinned ? '取消置顶' : '置顶'}">${isPinned ? '✓' : '⇧'}</button>
<button class="session-item-btn copy-id" title="复制 ID">ID</button>
<button class="session-item-btn edit" title="重命名">✎</button>
<button class="session-item-btn delete" title="删除">×</button>
@@ -1857,6 +2074,11 @@
copyTextToClipboard(session.id, '会话 ID 已复制');
return;
}
if (target.classList.contains('pin')) {
e.stopPropagation();
toggleSessionPinned(session);
return;
}
if (target.classList.contains('delete')) {
e.stopPropagation();
const doDelete = () => {
@@ -1928,7 +2150,13 @@
});
}
if (importSessionBtn) {
importSessionBtn.textContent = currentAgent === 'codex' ? '导入本地 Codex 会话' : '导入本地 Claude 会话';
if (isCodexAppAgent(currentAgent)) {
importSessionBtn.textContent = 'Codex App 暂不支持导入';
importSessionBtn.disabled = true;
} else {
importSessionBtn.textContent = currentAgent === 'codex' ? '导入本地 Codex 会话' : '导入本地 Claude 会话';
importSessionBtn.disabled = false;
}
}
}
@@ -1961,7 +2189,7 @@
clearSessionLoading();
setCurrentSessionRunningState(false);
currentCwd = null;
currentModel = currentAgent === 'claude' ? 'opus' : '';
currentModel = isCodexLikeAgent(currentAgent) ? '' : 'opus';
isGenerating = false;
generatingSessionId = null;
pendingText = '';
@@ -2152,7 +2380,7 @@
}
function setStatsDisplay(msg) {
if (currentAgent === 'codex' && msg && msg.totalUsage) {
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}` : '';
@@ -2204,7 +2432,7 @@
DEFAULT_CODEX_MODEL_OPTIONS.forEach((opt) => addBaseOption(opt.value, opt.label, opt.desc));
addBaseOption(currentModel, currentModel, '当前会话模型');
sessions
.filter((s) => normalizeAgent(s.agent) === 'codex' && s.id === currentSessionId)
.filter((s) => isCodexLikeAgent(s.agent) && s.id === currentSessionId)
.forEach((s) => addBaseOption(s.model, s.model, '当前会话已保存模型'));
return options;
@@ -2409,6 +2637,7 @@
cwd: snapshot.cwd || session.cwd || '',
projectName: snapshot.cwd ? getPathLeaf(snapshot.cwd) : session.projectName || '',
title: snapshot.title || session.title,
pinnedAt: snapshot.pinnedAt || session.pinnedAt || null,
}
: session
));
@@ -2471,6 +2700,10 @@
renderSessionList();
break;
case 'session_pinned':
applySessionPinnedState(msg.sessionId, msg.pinnedAt || null);
break;
case 'text_delta':
if (!ensureGeneratingForEvent(msg)) break;
pendingText += msg.text;
@@ -2488,6 +2721,7 @@
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;
@@ -2500,9 +2734,10 @@
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);
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;
@@ -2515,6 +2750,17 @@
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;
@@ -2558,6 +2804,13 @@
appendSystemMessage(msg.message);
break;
case 'codex_app_user_input_request':
if (msg.sessionId && msg.sessionId !== currentSessionId) {
showToast('Codex App 需要输入', msg.sessionId);
}
showCodexAppUserInputModal(msg);
break;
case 'mode_changed':
if (msg.mode && MODE_LABELS[msg.mode]) {
currentMode = msg.mode;
@@ -2714,6 +2967,10 @@
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;
@@ -2853,7 +3110,8 @@
function createMsgElement(role, content, attachments = [], meta = {}) {
const div = document.createElement('div');
const isCrossConversation = role === 'user' && !!meta.crossConversation;
div.className = `msg ${role}${role === 'assistant' ? ' agent-' + currentAgent : ''}${isCrossConversation ? ' cross-conversation' : ''}`;
const isCrossConversationReply = isCrossConversation && !!(meta.crossConversation.reply || meta.crossConversation.replyToRequestId);
div.className = `msg ${role}${role === 'assistant' ? ' agent-' + currentAgent : ''}${isCrossConversation ? ' cross-conversation' : ''}${isCrossConversationReply ? ' cross-conversation-reply' : ''}`;
if (role === 'system') {
const bubble = document.createElement('div');
@@ -2866,11 +3124,11 @@
const avatar = document.createElement('div');
avatar.className = 'msg-avatar';
if (isCrossConversation) {
avatar.textContent = '↗';
avatar.textContent = isCrossConversationReply ? '↩' : '↗';
} else if (role === 'user') {
avatar.textContent = 'U';
} else if (currentAgent === 'codex') {
avatar.innerHTML = `<img src="/codex.png" width="24" height="24" style="display:block;" alt="Codex">`;
} else if (isCodexLikeAgent(currentAgent)) {
avatar.innerHTML = `<img src="/codex.png" width="24" height="24" style="display:block;" alt="${isCodexAppAgent(currentAgent) ? 'Codex App' : 'Codex'}">`;
} else {
avatar.innerHTML = `<img src="/claude.png" width="24" height="24" style="display:block;" alt="Claude">`;
}
@@ -2888,7 +3146,9 @@
const label = document.createElement('span');
label.className = 'cross-conversation-label';
label.textContent = `来自「${sourceTitle}」的对话`;
label.textContent = isCrossConversationReply
? `来自「${sourceTitle}」的回复`
: `来自「${sourceTitle}」的对话`;
sourceMeta.appendChild(label);
if (sourceId) {
@@ -3090,6 +3350,26 @@
}
}
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') {
@@ -3183,6 +3463,7 @@
const FOLD_AT = 3;
let grouped = false;
for (const tc of m.toolCalls) {
if (isEmptyReasoningTool(tc)) continue;
const details = createToolCallElement(tc.id || `saved-${Math.random().toString(36).slice(2)}`, tc, true);
// 散落的 .tool-call 达到 FOLD_AT 个时,移入唯一 .tool-group
@@ -3548,7 +3829,7 @@
const kind = toolKind(tool);
if (tool.name === 'AskUserQuestion') {
details.open = true;
} else if (agent !== 'codex' && !done && kind === 'command_execution') {
} else if (!isCodexLikeAgent(agent) && !done && kind === 'command_execution') {
details.open = true;
}
@@ -3559,7 +3840,7 @@
return details;
}
function appendToolCall(toolUseId, name, input, done, kind = null, meta = null) {
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');
@@ -3568,6 +3849,8 @@
if (!toolsDiv) { toolsDiv = bubble; }
const tool = { id: toolUseId, name, input, kind, meta, done };
if (result !== undefined) tool.result = result;
if (isEmptyReasoningTool(tool)) return;
// 如果是 todo_list检查是否已存在相同 id 的 todo_list
if (kind === 'todo_list' && input?.id) {
@@ -3672,6 +3955,11 @@
};
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);
@@ -3690,6 +3978,9 @@
if (normalized === 'codex') {
return '删除本会话将同步删去本地 Codex rollout 历史与线程记录,不可恢复。确认删除?';
}
if (normalized === 'codexapp') {
return '删除本会话只会删除 cc-web 中的 Codex App 会话记录,不会清理本地 Codex App 线程历史。确认删除?';
}
return '删除本会话将同步删去本地 Claude 中的会话历史,不可恢复。确认删除?';
}
@@ -3868,7 +4159,26 @@
return;
}
const { groups: projectGroups, ungroupedSessions } = groupSessionsByProject(visibleSessions);
const { pinnedSessions, regularSessions } = splitPinnedSessions(visibleSessions);
if (pinnedSessions.length > 0) {
const pinnedGroupEl = document.createElement('section');
pinnedGroupEl.className = 'session-project-group session-pinned-group';
const pinnedHeader = document.createElement('div');
pinnedHeader.className = 'session-project-header session-pinned-header';
pinnedHeader.innerHTML = `
<span class="session-project-name">置顶</span>
<span class="session-project-header-actions">
<span class="session-project-count">${pinnedSessions.length}</span>
</span>
`;
pinnedGroupEl.appendChild(pinnedHeader);
for (const session of pinnedSessions) {
pinnedGroupEl.appendChild(createSessionListItem(session));
}
sessionList.appendChild(pinnedGroupEl);
}
const { groups: projectGroups, ungroupedSessions } = groupSessionsByProject(regularSessions);
for (const group of projectGroups) {
const groupEl = document.createElement('section');
groupEl.className = 'session-project-group';
@@ -4093,52 +4403,137 @@
}
}
// --- Slash Command Menu ---
function showCmdMenu(filter) {
const filtered = SLASH_COMMANDS.filter(c =>
c.cmd.startsWith(filter) || c.desc.includes(filter.slice(1))
);
// Exact match first (fixes /mode vs /model ambiguity)
filtered.sort((a, b) => (b.cmd === filter ? 1 : 0) - (a.cmd === filter ? 1 : 0));
if (filtered.length === 0) {
// --- 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 = filtered.map((c, i) =>
`<div class="cmd-item${i === 0 ? ' active' : ''}" data-cmd="${c.cmd}">
<span class="cmd-item-cmd">${c.cmd}</span>
<span class="cmd-item-desc">${c.desc}</span>
</div>`
).join('');
cmdMenu.innerHTML = safeItems.map((item, i) => {
const kindLabel = item.kind === 'skill'
? 'Skill'
: item.kind === 'prompt'
? 'Prompt'
: item.kind === 'file'
? (item.itemType === 'directory' ? 'Dir' : 'File')
: 'Cmd';
return `<div class="cmd-item${i === 0 ? ' active' : ''}" data-index="${i}">
<span class="cmd-item-kind">${kindLabel}</span>
<span class="cmd-item-main">
<span class="cmd-item-cmd">${escapeHtml(item.label || item.name || item.insertion || '')}</span>
<span class="cmd-item-desc">${escapeHtml(item.description || item.title || '')}</span>
</span>
</div>`;
}).join('');
cmdMenu._items = safeItems;
cmdMenu.hidden = false;
// Click handlers
cmdMenu.querySelectorAll('.cmd-item').forEach(el => {
cmdMenu.querySelectorAll('.cmd-item').forEach((el) => {
el.addEventListener('click', () => {
const cmd = el.dataset.cmd;
if (cmd === '/model') {
hideCmdMenu();
msgInput.value = '';
showModelPicker();
return;
}
if (cmd === '/mode') {
hideCmdMenu();
msgInput.value = '';
showModePicker();
return;
}
msgInput.value = cmd + ' ';
hideCmdMenu();
msgInput.focus();
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));
return;
}
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) {
@@ -4147,12 +4542,16 @@
items[cmdMenuIndex]?.classList.remove('active');
cmdMenuIndex = (cmdMenuIndex + direction + items.length) % items.length;
items[cmdMenuIndex]?.classList.add('active');
items[cmdMenuIndex]?.scrollIntoView({ block: 'nearest' });
}
function selectCmdMenuItem() {
const items = cmdMenu.querySelectorAll('.cmd-item');
if (cmdMenuIndex >= 0 && items[cmdMenuIndex]) {
const cmd = items[cmdMenuIndex].dataset.cmd;
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 = '';
@@ -4165,10 +4564,30 @@
showModePicker();
return;
}
msgInput.value = cmd + ' ';
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) ---
@@ -4232,10 +4651,10 @@
}
function showModelPicker() {
if (currentAgent === 'codex') {
if (isCodexLikeAgent(currentAgent)) {
const current = _splitCodexThinkingModel(currentModel || '');
const baseOptions = getCodexBaseModelOptions();
showOptionPicker('选择 Codex 模型', baseOptions, current.base || '', (baseValue) => {
showOptionPicker(`选择 ${isCodexAppAgent(currentAgent) ? 'Codex App' : 'Codex'} 模型`, baseOptions, current.base || '', (baseValue) => {
const base = String(baseValue || '').trim();
const thinkingOptions = [
{ value: '', label: '无 (默认)', desc: '不附加 (low/medium/high/xhigh) 后缀' },
@@ -4296,10 +4715,29 @@
return;
}
if ((!text && pendingAttachments.length === 0) || isGenerating || isBlockingSessionLoad()) 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;
}
messagesDiv.appendChild(createMsgElement('user', text, []));
scrollToBottom();
send({ type: 'message', text, sessionId: currentSessionId, mode: currentMode, agent: currentAgent });
msgInput.value = '';
autoResize();
return;
}
// Slash commands: don't show as user bubble
if (text.startsWith('/')) {
if (pendingAttachments.length > 0) {
@@ -4397,7 +4835,9 @@
});
importSessionBtn.addEventListener('click', () => {
newChatDropdown.hidden = true;
if (currentAgent === 'codex') {
if (isCodexAppAgent(currentAgent)) {
appendError('Codex App 模式暂不支持导入本地会话。');
} else if (currentAgent === 'codex') {
showImportCodexSessionModal();
} else {
showImportSessionModal();
@@ -4461,13 +4901,7 @@
msgInput.addEventListener('input', () => {
autoResize();
const val = msgInput.value;
// Show slash command menu
if (!noteMode && val.startsWith('/') && !val.includes('\n')) {
showCmdMenu(val);
} else {
hideCmdMenu();
}
requestComposerSuggestions();
});
msgInput.addEventListener('keydown', (e) => {
@@ -5116,7 +5550,7 @@
}
function showSettingsPanel() {
if (currentAgent === 'codex') {
if (isCodexLikeAgent(currentAgent)) {
showCodexSettingsPanel();
return;
}
@@ -6085,7 +6519,7 @@
// Register Service Worker for mobile push notifications
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});
navigator.serviceWorker.register(`/sw.js?v=${ASSET_VERSION}`).catch(() => {});
}
// Restore remembered password