feat: improve codex app controls and recovery
This commit is contained in:
150
public/app.js
150
public/app.js
@@ -2,7 +2,7 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const ASSET_VERSION = '20260614-divider-time-selectfix';
|
||||
const ASSET_VERSION = '20260615-reload-mcp';
|
||||
const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`;
|
||||
const RENDER_DEBOUNCE = 100;
|
||||
const COMPOSER_SUGGESTION_DEBOUNCE = 120;
|
||||
@@ -35,6 +35,9 @@
|
||||
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 上下文' },
|
||||
@@ -159,8 +162,10 @@
|
||||
let pendingInitialSessionLoad = false;
|
||||
let noteMode = false;
|
||||
let noteDraftSeq = 0;
|
||||
let isReloadingMcp = false;
|
||||
const pendingNotesByTarget = new Map();
|
||||
const userMessageIndex = new Map();
|
||||
const expandedOldSessionAgents = new Set();
|
||||
document.documentElement.dataset.dividerTime = showAgentDividerTime ? 'show' : 'hide';
|
||||
|
||||
// --- DOM ---
|
||||
@@ -191,6 +196,7 @@
|
||||
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');
|
||||
@@ -1087,6 +1093,32 @@
|
||||
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;
|
||||
@@ -1096,6 +1128,7 @@
|
||||
if (sendCancel && requestId) {
|
||||
send({
|
||||
type: 'codex_app_user_input_response',
|
||||
action: 'cancel',
|
||||
sessionId,
|
||||
requestId,
|
||||
answers: {},
|
||||
@@ -1219,6 +1252,7 @@
|
||||
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),
|
||||
@@ -2186,6 +2220,55 @@
|
||||
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;
|
||||
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 = `
|
||||
<span class="session-list-load-more-title">加载更多</span>
|
||||
<span class="session-list-load-more-meta">${hiddenCount} 条 7 天前会话</span>
|
||||
`;
|
||||
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
|
||||
@@ -2221,25 +2304,36 @@
|
||||
<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>
|
||||
<div class="session-item-more">
|
||||
<button class="session-item-btn more" type="button" title="更多操作" aria-label="更多操作" aria-haspopup="menu">⋯</button>
|
||||
<div class="session-item-menu" role="menu" aria-label="会话操作">
|
||||
<button class="session-item-menu-btn copy-id" type="button" role="menuitem">复制 ID</button>
|
||||
<button class="session-item-menu-btn edit" type="button" role="menuitem">重命名</button>
|
||||
<button class="session-item-menu-btn delete" type="button" role="menuitem">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
item.addEventListener('click', (e) => {
|
||||
const target = e.target;
|
||||
if (target.classList.contains('copy-id')) {
|
||||
const target = e.target instanceof Element
|
||||
? e.target.closest('.session-item-btn, .session-item-menu-btn')
|
||||
: null;
|
||||
if (target?.classList.contains('more')) {
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (target?.classList.contains('copy-id')) {
|
||||
e.stopPropagation();
|
||||
copyTextToClipboard(session.id, '会话 ID 已复制');
|
||||
return;
|
||||
}
|
||||
if (target.classList.contains('pin')) {
|
||||
if (target?.classList.contains('pin')) {
|
||||
e.stopPropagation();
|
||||
toggleSessionPinned(session);
|
||||
return;
|
||||
}
|
||||
if (target.classList.contains('delete')) {
|
||||
if (target?.classList.contains('delete')) {
|
||||
e.stopPropagation();
|
||||
const doDelete = () => {
|
||||
if (getLastSessionForAgent(currentAgent) === session.id) {
|
||||
@@ -2259,11 +2353,15 @@
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (target.classList.contains('edit')) {
|
||||
if (target?.classList.contains('edit')) {
|
||||
e.stopPropagation();
|
||||
startEditSessionTitle(item, session);
|
||||
return;
|
||||
}
|
||||
if (e.target instanceof Element && e.target.closest('.session-item-more')) {
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
openSession(session.id);
|
||||
});
|
||||
|
||||
@@ -2318,6 +2416,7 @@
|
||||
importSessionBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
updateReloadMcpButtonUI();
|
||||
}
|
||||
|
||||
function setCurrentAgent(agent) {
|
||||
@@ -2364,6 +2463,7 @@
|
||||
chatTitle.textContent = '新会话';
|
||||
updateSessionIdBadge();
|
||||
updateCwdBadge();
|
||||
updateReloadMcpButtonUI();
|
||||
messagesDiv.innerHTML = buildWelcomeMarkup(currentAgent);
|
||||
setStatsDisplay(null);
|
||||
renderPendingAttachments();
|
||||
@@ -3286,7 +3386,23 @@
|
||||
if (role === 'system') {
|
||||
const bubble = document.createElement('div');
|
||||
bubble.className = 'msg-bubble';
|
||||
bubble.textContent = content;
|
||||
const text = document.createElement('span');
|
||||
text.className = 'system-message-text';
|
||||
text.textContent = content;
|
||||
bubble.appendChild(text);
|
||||
|
||||
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);
|
||||
return div;
|
||||
}
|
||||
@@ -4564,6 +4680,7 @@
|
||||
}
|
||||
|
||||
const { pinnedSessions, regularSessions } = splitPinnedSessions(visibleSessions);
|
||||
const { visibleRegularSessions, hiddenOldSessions } = splitCollapsedOldSessions(regularSessions, pinnedSessions.length);
|
||||
if (pinnedSessions.length > 0) {
|
||||
const pinnedGroupEl = document.createElement('section');
|
||||
pinnedGroupEl.className = 'session-project-group session-pinned-group';
|
||||
@@ -4582,7 +4699,7 @@
|
||||
sessionList.appendChild(pinnedGroupEl);
|
||||
}
|
||||
|
||||
const { groups: projectGroups, ungroupedSessions } = groupSessionsByProject(regularSessions);
|
||||
const { groups: projectGroups, ungroupedSessions } = groupSessionsByProject(visibleRegularSessions);
|
||||
for (const group of projectGroups) {
|
||||
const groupEl = document.createElement('section');
|
||||
groupEl.className = 'session-project-group';
|
||||
@@ -4614,6 +4731,10 @@
|
||||
for (const s of ungroupedSessions) {
|
||||
sessionList.appendChild(createSessionListItem(s));
|
||||
}
|
||||
|
||||
if (hiddenOldSessions.length > 0) {
|
||||
sessionList.appendChild(createOldSessionLoadMoreButton(hiddenOldSessions.length));
|
||||
}
|
||||
}
|
||||
|
||||
function startEditSessionTitle(itemEl, session) {
|
||||
@@ -5253,6 +5374,13 @@
|
||||
});
|
||||
}
|
||||
|
||||
if (reloadMcpBtn) {
|
||||
reloadMcpBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
reloadCurrentMcpServers();
|
||||
});
|
||||
}
|
||||
|
||||
// Split new-chat button
|
||||
newChatBtn.addEventListener('click', () => showNewSessionModal());
|
||||
newChatArrow.addEventListener('click', (e) => {
|
||||
|
||||
Reference in New Issue
Block a user