feat: enhance codex app and cross-conversation messaging
This commit is contained in:
594
public/app.js
594
public/app.js
@@ -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
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
document.documentElement.dataset.theme = theme;
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link rel="stylesheet" href="style.css?v=20260613-codexapp-tools2">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -65,6 +65,7 @@
|
||||
<div id="chat-agent-menu" class="chat-agent-menu" hidden>
|
||||
<button type="button" class="chat-agent-option active" data-agent="claude">Claude</button>
|
||||
<button type="button" class="chat-agent-option" data-agent="codex">Codex</button>
|
||||
<button type="button" class="chat-agent-option" data-agent="codexapp">Codex App</button>
|
||||
</div>
|
||||
<span id="chat-runtime-state" class="chat-runtime-state" hidden>运行中</span>
|
||||
<button id="chat-cwd" class="chat-cwd" type="button" hidden></button>
|
||||
@@ -94,6 +95,7 @@
|
||||
|
||||
<div class="input-area">
|
||||
<div id="attachment-tray" class="attachment-tray" hidden></div>
|
||||
<div id="pending-notes-tray" class="pending-notes-tray" hidden></div>
|
||||
<div class="input-wrapper">
|
||||
<input id="image-upload-input" type="file" accept="image/png,image/jpeg,image/webp,image/gif" multiple hidden>
|
||||
<button id="attach-btn" class="attach-btn" title="上传图片" type="button">
|
||||
@@ -137,6 +139,6 @@
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.1/marked.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
||||
<script src="app.js"></script>
|
||||
<script src="app.js?v=20260613-codexapp-tools2"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
269
public/style.css
269
public/style.css
@@ -661,6 +661,10 @@ body.session-loading-active {
|
||||
.session-project-group {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.session-pinned-group {
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid rgba(221, 208, 192, 0.72);
|
||||
}
|
||||
.session-project-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
@@ -729,6 +733,9 @@ body.session-loading-active {
|
||||
outline: 2px solid rgba(192, 85, 58, 0.22);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.session-pinned-header {
|
||||
color: var(--accent);
|
||||
}
|
||||
.session-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -741,6 +748,16 @@ body.session-loading-active {
|
||||
}
|
||||
.session-item:hover { background: var(--bg-tertiary); }
|
||||
.session-item.active { background: var(--accent-light); }
|
||||
.session-item.pinned::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 9px;
|
||||
bottom: 9px;
|
||||
width: 3px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
}
|
||||
.session-item-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -757,6 +774,16 @@ body.session-loading-active {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.session-item.active .session-item-title { color: var(--accent); font-weight: 500; }
|
||||
.session-item-pin-badge {
|
||||
flex-shrink: 0;
|
||||
padding: 1px 5px;
|
||||
border-radius: 999px;
|
||||
background: rgba(192, 85, 58, 0.11);
|
||||
color: var(--accent);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.session-item-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -810,6 +837,18 @@ body.session-loading-active {
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.session-item-btn.pin {
|
||||
min-width: 24px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.session-item-btn.pin.active {
|
||||
color: var(--accent);
|
||||
background: rgba(192, 85, 58, 0.1);
|
||||
}
|
||||
.session-item-btn.pin:hover {
|
||||
color: var(--accent);
|
||||
background: rgba(192, 85, 58, 0.12);
|
||||
}
|
||||
.session-item-btn.delete:hover { color: var(--danger); background: var(--accent-light); }
|
||||
/* Inline edit in sidebar */
|
||||
.session-item-edit-input {
|
||||
@@ -1028,6 +1067,8 @@ body.session-loading-active {
|
||||
overflow-x: hidden;
|
||||
padding: 16px;
|
||||
padding-right: 20px;
|
||||
padding-bottom: 24px;
|
||||
scroll-padding-bottom: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
@@ -1298,6 +1339,9 @@ body.session-loading-active {
|
||||
background: var(--info);
|
||||
color: #fff;
|
||||
}
|
||||
.msg.user.cross-conversation-reply .msg-avatar {
|
||||
background: var(--success);
|
||||
}
|
||||
.msg.user.cross-conversation .msg-bubble {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.76), transparent),
|
||||
@@ -1306,6 +1350,22 @@ body.session-loading-active {
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 10px 22px rgba(45, 31, 20, 0.05);
|
||||
}
|
||||
.msg.user.cross-conversation-reply .msg-bubble {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.78), transparent),
|
||||
rgba(93, 138, 84, 0.12);
|
||||
border-color: rgba(93, 138, 84, 0.28);
|
||||
}
|
||||
.msg.user.cross-conversation-reply .cross-conversation-meta,
|
||||
.msg.user.cross-conversation-reply .cross-conversation-id-btn {
|
||||
color: var(--success);
|
||||
}
|
||||
.msg.user.cross-conversation-reply .cross-conversation-id-btn {
|
||||
border-color: rgba(93, 138, 84, 0.28);
|
||||
}
|
||||
.msg.user.cross-conversation-reply .cross-conversation-id-btn:hover {
|
||||
background: rgba(93, 138, 84, 0.14);
|
||||
}
|
||||
.cross-conversation-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1340,24 +1400,6 @@ body.session-loading-active {
|
||||
border-bottom-left-radius: 4px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.msg.note {
|
||||
align-self: flex-end;
|
||||
flex-direction: row-reverse;
|
||||
max-width: min(680px, 85%);
|
||||
}
|
||||
.msg.note .note-avatar {
|
||||
background: var(--note-accent);
|
||||
color: #fff;
|
||||
}
|
||||
.msg.note .note-bubble {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.72), transparent),
|
||||
var(--note-bg);
|
||||
border: 1px solid var(--note-border);
|
||||
border-bottom-right-radius: 4px;
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 10px 22px rgba(45, 31, 20, 0.05);
|
||||
}
|
||||
.note-meta {
|
||||
margin-bottom: 6px;
|
||||
color: var(--note-accent);
|
||||
@@ -1452,6 +1494,11 @@ body.session-loading-active {
|
||||
/* Markdown content */
|
||||
.msg-bubble p { margin: 0 0 8px 0; }
|
||||
.msg-bubble p:last-child { margin-bottom: 0; }
|
||||
.msg-bubble hr {
|
||||
border: 0;
|
||||
border-top: 1px solid rgba(122, 104, 82, 0.22);
|
||||
margin: 10px 0 11px;
|
||||
}
|
||||
.msg-bubble ul, .msg-bubble ol { margin: 4px 0 8px 20px; }
|
||||
.msg-bubble li { margin-bottom: 2px; }
|
||||
.msg-bubble h1, .msg-bubble h2, .msg-bubble h3, .msg-bubble h4 {
|
||||
@@ -1585,7 +1632,7 @@ body.session-loading-active {
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-secondary);
|
||||
display: flex;
|
||||
align-items: anchor-center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
user-select: none;
|
||||
list-style: none;
|
||||
@@ -1891,7 +1938,7 @@ body.session-loading-active {
|
||||
30% { transform: translateY(-7px); }
|
||||
}
|
||||
|
||||
/* === Slash Command Menu === */
|
||||
/* === Composer Modifier Menu === */
|
||||
.cmd-menu {
|
||||
position: absolute;
|
||||
bottom: 80px;
|
||||
@@ -1904,27 +1951,64 @@ body.session-loading-active {
|
||||
padding: 6px;
|
||||
min-width: 240px;
|
||||
max-width: 320px;
|
||||
max-height: min(52vh, 360px);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
z-index: 50;
|
||||
}
|
||||
.cmd-menu::-webkit-scrollbar { width: 8px; }
|
||||
.cmd-menu::-webkit-scrollbar-track { background: transparent; }
|
||||
.cmd-menu::-webkit-scrollbar-thumb {
|
||||
background: rgba(95, 74, 58, 0.22);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.cmd-menu::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(95, 74, 58, 0.34);
|
||||
}
|
||||
.cmd-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
min-width: 0;
|
||||
}
|
||||
.cmd-item:hover, .cmd-item.active { background: var(--accent-light); }
|
||||
.cmd-item-kind {
|
||||
flex: 0 0 auto;
|
||||
min-width: 42px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
background: rgba(192, 85, 58, 0.1);
|
||||
color: var(--accent);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.cmd-item-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.cmd-item-cmd {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--accent);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cmd-item-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* === Input Area === */
|
||||
@@ -2001,9 +2085,63 @@ body.session-loading-active {
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.pending-notes-tray {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
max-height: min(32vh, 220px);
|
||||
margin: 0 auto 10px;
|
||||
padding: 2px 3px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--note-border) transparent;
|
||||
}
|
||||
.pending-notes-tray[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.pending-notes-tray::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.pending-notes-tray::-webkit-scrollbar-thumb {
|
||||
background: var(--note-border);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.pending-note {
|
||||
display: grid;
|
||||
grid-template-columns: 28px minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 8px;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
.pending-note .note-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 9px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--note-accent);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pending-note .note-bubble {
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.72), transparent),
|
||||
var(--note-bg);
|
||||
border: 1px solid var(--note-border);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 8px 18px rgba(45, 31, 20, 0.05);
|
||||
}
|
||||
.input-wrapper {
|
||||
display: flex;
|
||||
align-items: anchor-center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border-color);
|
||||
@@ -2011,6 +2149,7 @@ body.session-loading-active {
|
||||
padding: 8px 12px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.input-wrapper.drag-active {
|
||||
@@ -2087,6 +2226,7 @@ body.session-loading-active {
|
||||
}
|
||||
#msg-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
@@ -2204,7 +2344,11 @@ body.session-loading-active {
|
||||
.menu-btn { display: block; }
|
||||
.msg { max-width: 95%; }
|
||||
.input-area { padding: 8px 10px; padding-bottom: max(10px, var(--safe-bottom)); }
|
||||
.messages { padding: 12px 8px; gap: 10px; }
|
||||
.messages {
|
||||
padding: 12px 8px 24px;
|
||||
scroll-padding-bottom: 24px;
|
||||
gap: 10px;
|
||||
}
|
||||
.session-item-actions { display: flex; }
|
||||
.cmd-menu { left: 10px; right: 10px; transform: none; min-width: auto; bottom: 72px; }
|
||||
.option-picker { left: 10px; right: 10px; transform: none; min-width: auto; bottom: 72px; }
|
||||
@@ -2252,7 +2396,9 @@ body.session-loading-active {
|
||||
.attach-btn { width: 34px; height: 34px; border-radius: 10px; }
|
||||
.note-mode-btn { width: 34px; height: 34px; border-radius: 10px; }
|
||||
.send-btn, .abort-btn { width: 34px; height: 34px; }
|
||||
.msg.note { max-width: 92%; }
|
||||
.pending-notes-tray { max-height: 34vh; margin-bottom: 8px; }
|
||||
.pending-note { grid-template-columns: 26px minmax(0, 1fr); gap: 7px; }
|
||||
.pending-note .note-avatar { width: 26px; height: 26px; border-radius: 8px; font-size: 11px; }
|
||||
.note-actions { gap: 5px; }
|
||||
.note-action { min-height: 28px; padding: 0 8px; }
|
||||
.note-edit-input { min-width: 0; }
|
||||
@@ -3037,6 +3183,81 @@ html[data-theme='coolvibe'] .settings-back:hover {
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
}
|
||||
.codex-user-input-panel {
|
||||
max-width: 520px;
|
||||
}
|
||||
.codex-user-input-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.codex-user-input-question {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.codex-user-input-kicker {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
.codex-user-input-prompt {
|
||||
color: var(--text-primary);
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.codex-user-input-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.codex-user-input-option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.codex-user-input-option:hover {
|
||||
border-color: rgba(192, 85, 58, 0.36);
|
||||
}
|
||||
.codex-user-input-option input[type='radio'] {
|
||||
margin-top: 3px;
|
||||
}
|
||||
.codex-user-input-option-copy {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.codex-user-input-option-label {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
.codex-user-input-option-desc {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.codex-user-input-text {
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
padding: 8px 10px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
outline: none;
|
||||
}
|
||||
.codex-user-input-text:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.modal-quick-picks {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
Reference in New Issue
Block a user