feat: improve codex app controls and recovery
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
'use strict';
|
||||
|
||||
const CODEX_APP_ONCE_NOTICE_PATTERNS = [
|
||||
/^Under-development features enabled:/i,
|
||||
/^Heads up: Long threads and multiple compactions/i,
|
||||
];
|
||||
|
||||
function createCodexAppRuntime(deps = {}) {
|
||||
const {
|
||||
wsSend,
|
||||
@@ -14,6 +19,23 @@ function createCodexAppRuntime(deps = {}) {
|
||||
return text.length > maxLen ? `${text.slice(0, maxLen)}...` : value;
|
||||
}
|
||||
|
||||
const shownOnceNoticeKeys = new Set();
|
||||
|
||||
function normalizeNoticeMessage(message) {
|
||||
return String(message || '').trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function shouldShowRuntimeNotice(method, message) {
|
||||
const normalized = normalizeNoticeMessage(message);
|
||||
const isOnceNotice = CODEX_APP_ONCE_NOTICE_PATTERNS.some((pattern) => pattern.test(normalized));
|
||||
if (!isOnceNotice) return true;
|
||||
|
||||
const key = `${method}:${normalized}`;
|
||||
if (shownOnceNoticeKeys.has(key)) return false;
|
||||
shownOnceNoticeKeys.add(key);
|
||||
return true;
|
||||
}
|
||||
|
||||
function sendRuntime(entry, sessionId, payload) {
|
||||
wsSend(entry.ws, { ...payload, sessionId });
|
||||
}
|
||||
@@ -483,8 +505,10 @@ function createCodexAppRuntime(deps = {}) {
|
||||
const message = params.message || params.title || '';
|
||||
if (message) {
|
||||
if (method === 'error') entry.lastError = message;
|
||||
if (method === 'error' || shouldShowRuntimeNotice(method, message)) {
|
||||
sendRuntime(entry, sessionId, { type: 'system_message', message });
|
||||
}
|
||||
}
|
||||
return { done: false };
|
||||
}
|
||||
|
||||
|
||||
@@ -133,6 +133,10 @@ function createCodexAppServerClient(options = {}) {
|
||||
sendRaw({ method, params });
|
||||
}
|
||||
|
||||
function reloadMcpServers() {
|
||||
return request('config/mcpServer/reload', {}, 30000);
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (initPromise) return initPromise;
|
||||
exited = false;
|
||||
@@ -212,6 +216,7 @@ function createCodexAppServerClient(options = {}) {
|
||||
stop,
|
||||
request,
|
||||
notification,
|
||||
reloadMcpServers,
|
||||
isRunning,
|
||||
pid: () => proc?.pid || null,
|
||||
};
|
||||
|
||||
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) => {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
document.documentElement.dataset.dividerTime = dividerTime;
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="style.css?v=20260614-divider-time-selectfix">
|
||||
<link rel="stylesheet" href="style.css?v=20260615-reload-mcp">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -102,6 +102,7 @@
|
||||
<button id="user-outline-btn" class="user-outline-btn" type="button" aria-expanded="false" aria-controls="user-outline-panel" title="定位用户消息">定位</button>
|
||||
<div id="user-outline-panel" class="user-outline-panel" hidden></div>
|
||||
</div>
|
||||
<button id="reload-mcp-btn" class="reload-mcp-btn" type="button" title="重载 Codex App MCP 配置" hidden>重载 MCP</button>
|
||||
<span id="cost-display" class="cost-display" hidden></span>
|
||||
</div>
|
||||
<div id="attachment-tray" class="attachment-tray" hidden></div>
|
||||
@@ -149,6 +150,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?v=20260614-divider-time-selectfix"></script>
|
||||
<script src="app.js?v=20260615-reload-mcp"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
151
public/style.css
151
public/style.css
@@ -1194,32 +1194,39 @@ body.session-loading-active {
|
||||
}
|
||||
.session-item-actions {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-left: 4px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.session-item:hover .session-item-actions { display: flex; }
|
||||
.session-item:hover .session-item-actions,
|
||||
.session-item:focus-within .session-item-actions {
|
||||
display: flex;
|
||||
}
|
||||
.session-item-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 2px 5px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
border-radius: 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
.session-item-btn:hover { color: var(--text-primary); background: var(--bg-tertiary); }
|
||||
.session-item-btn.copy-id {
|
||||
min-width: 24px;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.session-item-btn.pin {
|
||||
min-width: 24px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.session-item-btn.more {
|
||||
font-size: 16px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.session-item-btn.pin.active {
|
||||
color: var(--accent);
|
||||
background: rgba(192, 85, 58, 0.1);
|
||||
@@ -1228,7 +1235,88 @@ body.session-loading-active {
|
||||
color: var(--accent);
|
||||
background: rgba(192, 85, 58, 0.12);
|
||||
}
|
||||
.session-item-btn.delete:hover { color: var(--danger); background: var(--accent-light); }
|
||||
.session-item-more {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
.session-item-menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 6px);
|
||||
z-index: 20;
|
||||
display: none;
|
||||
min-width: 104px;
|
||||
padding: 5px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary);
|
||||
box-shadow: 0 10px 24px rgba(61, 42, 26, 0.14);
|
||||
}
|
||||
.session-item-more:hover .session-item-menu,
|
||||
.session-item-more:focus-within .session-item-menu {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
.session-item-menu-btn {
|
||||
width: 100%;
|
||||
padding: 7px 9px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.session-item-menu-btn:hover,
|
||||
.session-item-menu-btn:focus-visible {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.session-item-menu-btn.delete:hover,
|
||||
.session-item-menu-btn.delete:focus-visible {
|
||||
background: var(--accent-light);
|
||||
color: var(--danger);
|
||||
}
|
||||
.session-list-load-more {
|
||||
width: calc(100% - 4px);
|
||||
margin: 6px 2px 10px;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 249, 242, 0.72);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
text-align: left;
|
||||
transition: background 0.16s, border-color 0.16s, color 0.16s, transform 0.16s;
|
||||
}
|
||||
.session-list-load-more:hover {
|
||||
background: var(--bg-tertiary);
|
||||
border-color: rgba(192, 85, 58, 0.24);
|
||||
color: var(--accent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.session-list-load-more:focus-visible {
|
||||
outline: 2px solid rgba(192, 85, 58, 0.22);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.session-list-load-more-title {
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.session-list-load-more-meta {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
/* Inline edit in sidebar */
|
||||
.session-item-edit-input {
|
||||
flex: 1;
|
||||
@@ -1329,7 +1417,8 @@ body.session-loading-active {
|
||||
.chat-agent-btn:disabled,
|
||||
.chat-cwd:disabled,
|
||||
.mode-select:disabled,
|
||||
.user-outline-btn:disabled {
|
||||
.user-outline-btn:disabled,
|
||||
.reload-mcp-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
@@ -1428,7 +1517,8 @@ body.session-loading-active {
|
||||
display: none !important;
|
||||
}
|
||||
.cost-display:empty { display: none; }
|
||||
.user-outline-btn {
|
||||
.user-outline-btn,
|
||||
.reload-mcp-btn {
|
||||
appearance: none;
|
||||
border: 1px solid rgba(91, 126, 161, 0.22);
|
||||
border-radius: 999px;
|
||||
@@ -1446,7 +1536,8 @@ body.session-loading-active {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.user-outline-btn:hover {
|
||||
.user-outline-btn:hover,
|
||||
.reload-mcp-btn:hover:not(:disabled) {
|
||||
background: rgba(91, 126, 161, 0.16);
|
||||
border-color: rgba(91, 126, 161, 0.34);
|
||||
}
|
||||
@@ -1987,9 +2078,37 @@ body.session-loading-active {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
padding: 10px 16px;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
white-space: pre-line;
|
||||
}
|
||||
.msg.system .system-message-text {
|
||||
display: block;
|
||||
padding-right: 26px;
|
||||
}
|
||||
.msg.system .system-message-close {
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
font: inherit;
|
||||
font-size: 16px;
|
||||
height: 24px;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
right: 7px;
|
||||
top: 7px;
|
||||
width: 24px;
|
||||
}
|
||||
.msg.system .system-message-close:hover {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Markdown content */
|
||||
.msg-bubble p { margin: 0 0 8px 0; }
|
||||
@@ -2930,7 +3049,8 @@ html[data-divider-time='hide'] .msg-bubble .agent-message-divider span {
|
||||
padding: 4px 20px 4px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.user-outline-btn {
|
||||
.user-outline-btn,
|
||||
.reload-mcp-btn {
|
||||
padding: 4px 8px;
|
||||
font-size: 10px;
|
||||
}
|
||||
@@ -2975,6 +3095,7 @@ html[data-divider-time='hide'] .msg-bubble .agent-message-divider span {
|
||||
.chat-cwd,
|
||||
.mode-select,
|
||||
.user-outline-btn,
|
||||
.reload-mcp-btn,
|
||||
.chat-runtime-state {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
@@ -4548,10 +4669,12 @@ html[data-theme='coolvibe'] .settings-back:hover {
|
||||
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .session-list-empty,
|
||||
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .session-project-header,
|
||||
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .session-project-create,
|
||||
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .session-list-load-more,
|
||||
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .chat-agent-btn,
|
||||
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .mode-select,
|
||||
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .chat-cwd,
|
||||
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .user-outline-btn,
|
||||
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .reload-mcp-btn,
|
||||
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .user-outline-item,
|
||||
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .settings-back,
|
||||
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .settings-nav-card,
|
||||
|
||||
@@ -25,6 +25,7 @@ if (args[0] !== 'app-server') {
|
||||
const threads = new Map();
|
||||
const pendingServerRequests = new Map();
|
||||
let nextServerRequestId = 1;
|
||||
let mcpReloadCount = 0;
|
||||
|
||||
function send(message) {
|
||||
process.stdout.write(`${JSON.stringify(message)}\n`);
|
||||
@@ -429,6 +430,20 @@ function startTurn(params) {
|
||||
},
|
||||
});
|
||||
|
||||
if (/runtime warning/i.test(text)) {
|
||||
const message = 'Heads up: Long threads and multiple compactions can cause the model to be less accurate. Start a new thread when possible to keep threads small and targeted.';
|
||||
for (let i = 0; i < 2; i += 1) {
|
||||
send({
|
||||
method: 'warning',
|
||||
params: {
|
||||
threadId: thread.id,
|
||||
turnId,
|
||||
message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (/collaboration/i.test(text)) {
|
||||
completeTurn(thread, turnId, `collaboration mode: ${collaborationSummary(params)}`);
|
||||
return { turn: { id: turnId, status: 'running', items: [] } };
|
||||
@@ -516,6 +531,11 @@ function handleRequest(message) {
|
||||
send({ id, result: { data: [{ mode: 'default' }, { mode: 'plan' }] } });
|
||||
return;
|
||||
}
|
||||
if (method === 'config/mcpServer/reload') {
|
||||
mcpReloadCount += 1;
|
||||
send({ id, result: { reloaded: true, reloadCount: mcpReloadCount } });
|
||||
return;
|
||||
}
|
||||
if (method === 'thread/start') {
|
||||
const thread = ensureThread(null, params);
|
||||
send({ id, result: { thread: threadPayload(thread), model: params.model || 'gpt-5.5', cwd: thread.cwd, modelProvider: 'mock', approvalPolicy: params.approvalPolicy || 'never', approvalsReviewer: 'user', sandbox: params.sandbox || 'danger-full-access' } });
|
||||
|
||||
@@ -168,6 +168,20 @@ async function fetchAuthedJson(port, token, pathname) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function postAuthedJson(port, token, pathname, body = {}) {
|
||||
const response = await fetch(`http://127.0.0.1:${port}${pathname}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const payload = await response.json();
|
||||
assert(response.ok && payload.ok, `POST failed for ${pathname}: ${payload.message || response.status}`);
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function callInternalMcp(port, token, payload) {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/internal/mcp`, {
|
||||
method: 'POST',
|
||||
@@ -785,6 +799,22 @@ async function main() {
|
||||
assert(/"hasTopLevelEffort":false/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration turn should not duplicate effort at top level');
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
||||
|
||||
ws.send(JSON.stringify({ type: 'message', text: 'codexapp runtime warning prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||||
const codexAppRuntimeWarning = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'system_message' &&
|
||||
msg.sessionId === codexAppSession.sessionId &&
|
||||
/Long threads and multiple compactions/.test(msg.message || '')
|
||||
));
|
||||
assert(/Long threads and multiple compactions/.test(codexAppRuntimeWarning.message || ''), 'Codex App should surface the first runtime warning');
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
||||
await sleep(150);
|
||||
const duplicateRuntimeWarnings = messages.filter((msg) => (
|
||||
msg.type === 'system_message' &&
|
||||
msg.sessionId === codexAppSession.sessionId &&
|
||||
/Long threads and multiple compactions/.test(msg.message || '')
|
||||
));
|
||||
assert(duplicateRuntimeWarnings.length === 0, 'Codex App should suppress duplicate runtime warning banners');
|
||||
|
||||
ws.send(JSON.stringify({ type: 'message', text: 'codexapp empty reasoning prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
||||
const storedCodexAppAfterReasoning = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
|
||||
@@ -804,6 +834,10 @@ async function main() {
|
||||
assert(storedCodexApp.messages.some((message) => message.role === 'assistant' && /codexapp tool prompt/.test(String(message.content || ''))), 'Codex App assistant response should be persisted');
|
||||
assert((storedCodexApp.totalUsage?.inputTokens || 0) > 0, 'Codex App token usage should be persisted');
|
||||
|
||||
const reloadMcpResult = await postAuthedJson(port, token, `/api/sessions/${codexAppSession.sessionId}/reload-mcp`);
|
||||
assert(reloadMcpResult.sessionId === codexAppSession.sessionId, 'Codex App MCP reload should return the target session id');
|
||||
assert(reloadMcpResult.result?.reloaded === true, 'Codex App MCP reload should call app-server config/mcpServer/reload');
|
||||
|
||||
ws.send(JSON.stringify({ type: 'message', text: 'codexapp dynamic prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||||
const codexAppDynamicTool = await nextMessage(messages, ws, (msg) => msg.type === 'tool_end' && msg.sessionId === codexAppSession.sessionId && msg.toolUseId === 'mcp-ccweb-list');
|
||||
assert(codexAppDynamicTool.kind === 'mcp_tool_call', 'Codex App should surface ccweb MCP tool calls');
|
||||
@@ -835,10 +869,17 @@ async function main() {
|
||||
assert(guidedRequest.questions?.[0]?.id === 'choice', 'Codex App should forward request_user_input questions');
|
||||
ws.send(JSON.stringify({
|
||||
type: 'codex_app_user_input_response',
|
||||
action: 'submit',
|
||||
sessionId: codexAppSession.sessionId,
|
||||
requestId: guidedRequest.requestId,
|
||||
answers: { choice: { answers: ['A'] } },
|
||||
}));
|
||||
const guidedSubmitted = await nextMessage(messages, ws, (msg) =>
|
||||
msg.type === 'system_message' &&
|
||||
msg.sessionId === codexAppSession.sessionId &&
|
||||
/已提交.*引导输入/.test(msg.message || '')
|
||||
);
|
||||
assert(/已提交.*引导输入/.test(guidedSubmitted.message || ''), 'Codex App should show guided input submission hint');
|
||||
const guidedDelta = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /guided answer: A/.test(msg.text || ''));
|
||||
assert(/guided answer: A/.test(guidedDelta.text || ''), 'Codex App should continue after guided input response');
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
||||
|
||||
79
server.js
79
server.js
@@ -2015,6 +2015,69 @@ function getRuntimeSessionId(session) {
|
||||
return session.claudeSessionId || null;
|
||||
}
|
||||
|
||||
async function handleReloadMcpApi(req, res, rawSessionId) {
|
||||
const token = extractBearerToken(req);
|
||||
if (!token || !activeTokens.has(token)) {
|
||||
return jsonResponse(res, 401, { ok: false, message: 'Not authenticated' });
|
||||
}
|
||||
|
||||
const sessionId = sanitizeId(rawSessionId || '');
|
||||
if (!sessionId) {
|
||||
return jsonResponse(res, 400, { ok: false, code: 'missing_session_id', message: '缺少会话 ID' });
|
||||
}
|
||||
|
||||
const session = loadSession(sessionId);
|
||||
if (!session) {
|
||||
return jsonResponse(res, 404, { ok: false, code: 'session_not_found', message: '会话不存在' });
|
||||
}
|
||||
|
||||
if (!isCodexAppSession(session)) {
|
||||
return jsonResponse(res, 400, {
|
||||
ok: false,
|
||||
code: 'reload_mcp_unsupported_agent',
|
||||
message: '重载 MCP 仅支持 Codex App 会话。旧 Codex 会话请重启本地 Codex 后再继续。',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const clientResult = getCodexAppClient();
|
||||
if (clientResult.error) {
|
||||
return jsonResponse(res, 500, {
|
||||
ok: false,
|
||||
code: 'codexapp_client_unavailable',
|
||||
message: clientResult.error,
|
||||
});
|
||||
}
|
||||
|
||||
const client = clientResult.client;
|
||||
await client.start();
|
||||
const result = typeof client.reloadMcpServers === 'function'
|
||||
? await client.reloadMcpServers()
|
||||
: await client.request('config/mcpServer/reload', {}, 30000);
|
||||
|
||||
plog('INFO', 'codex_app_mcp_reload_requested', {
|
||||
sessionId: sessionId.slice(0, 8),
|
||||
threadId: getRuntimeSessionId(session) || null,
|
||||
});
|
||||
|
||||
return jsonResponse(res, 200, {
|
||||
ok: true,
|
||||
sessionId,
|
||||
threadId: getRuntimeSessionId(session) || null,
|
||||
result: result || {},
|
||||
});
|
||||
} catch (err) {
|
||||
const unsupported = err?.code === -32601 || /not found|unknown|unsupported|method/i.test(String(err?.message || ''));
|
||||
return jsonResponse(res, unsupported ? 501 : 500, {
|
||||
ok: false,
|
||||
code: unsupported ? 'codexapp_reload_mcp_unsupported' : 'codexapp_reload_mcp_failed',
|
||||
message: unsupported
|
||||
? `当前 Codex app-server 不支持重载 MCP,请重启 Codex App。${err?.message ? `(${err.message})` : ''}`
|
||||
: `重载 MCP 失败: ${err?.message || err}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function setRuntimeSessionId(session, runtimeId) {
|
||||
if (!session) return;
|
||||
const agent = getSessionAgent(session);
|
||||
@@ -2900,6 +2963,11 @@ const server = http.createServer((req, res) => {
|
||||
return handleInternalMcpApi(req, res);
|
||||
}
|
||||
|
||||
const reloadMcpMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/reload-mcp$/);
|
||||
if (req.method === 'POST' && reloadMcpMatch) {
|
||||
return handleReloadMcpApi(req, res, decodeURIComponent(reloadMcpMatch[1] || ''));
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/api/attachments') {
|
||||
const token = extractBearerToken(req);
|
||||
if (!token || !activeTokens.has(token)) {
|
||||
@@ -4554,7 +4622,16 @@ function handleCodexAppUserInputResponse(ws, msg = {}) {
|
||||
|
||||
pendingCodexAppUserInputs.delete(requestId);
|
||||
clearTimeout(pending.timer);
|
||||
pending.resolve(normalizeCodexAppUserInputAnswers(msg.answers || {}));
|
||||
const action = String(msg.action || 'submit').trim();
|
||||
const isCancel = action === 'cancel';
|
||||
if (!isCancel) {
|
||||
wsSend(ws, {
|
||||
type: 'system_message',
|
||||
sessionId: pending.sessionId,
|
||||
message: '已提交 Codex App 引导输入。',
|
||||
});
|
||||
}
|
||||
pending.resolve(isCancel ? { answers: {} } : normalizeCodexAppUserInputAnswers(msg.answers || {}));
|
||||
}
|
||||
|
||||
function resolvePendingCodexAppUserInputsForSession(sessionId) {
|
||||
|
||||
Reference in New Issue
Block a user