diff --git a/public/app.js b/public/app.js index 33f036e..fccd169 100644 --- a/public/app.js +++ b/public/app.js @@ -2,7 +2,7 @@ (function () { 'use strict'; - const ASSET_VERSION = '20260615-reload-mcp'; + const ASSET_VERSION = '20260615-codexapp-steer-status'; const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`; const RENDER_DEBOUNCE = 100; const COMPOSER_SUGGESTION_DEBOUNCE = 120; @@ -3066,7 +3066,16 @@ case 'system_message': if (!isCurrentSessionEvent(msg)) break; - appendSystemMessage(msg.message); + appendSystemMessage(msg.message, { + tone: msg.tone, + transient: msg.transient, + autoDismissMs: msg.autoDismissMs, + }); + break; + + case 'codex_app_steer_status': + if (!isCurrentSessionEvent(msg)) break; + updateCodexAppSteerMessage(msg.clientMessageId, msg.status, msg.message); break; case 'codex_app_user_input_request': @@ -3167,7 +3176,10 @@ break; } if (pendingNewSessionRequest) pendingNewSessionRequest = null; - appendError(msg.message); + appendError(msg.message, { + transient: msg.transient, + autoDismissMs: msg.autoDismissMs, + }); clearSessionLoading(); if (!isGenerating && currentSessionId) { setCurrentSessionRunningState(!!getSessionMeta(currentSessionId)?.isRunning); @@ -3372,6 +3384,54 @@ catch { return escapeHtml(text); } } + function codexAppSteerStatusLabel(status) { + if (status === 'inserted') return '已插入'; + if (status === 'failed') return '插入失败'; + return '引导中...'; + } + + function setCodexAppSteerStatusElement(element, status, message) { + if (!element) return false; + const normalized = ['pending', 'inserted', 'failed'].includes(status) ? status : 'pending'; + element.classList.add('codex-steer-message'); + element.classList.toggle('codex-steer-pending', normalized === 'pending'); + element.classList.toggle('codex-steer-inserted', normalized === 'inserted'); + element.classList.toggle('codex-steer-failed', normalized === 'failed'); + const bubble = element.querySelector('.msg-bubble'); + if (!bubble) return false; + let statusEl = bubble.querySelector('.codex-steer-status'); + if (!statusEl) { + statusEl = document.createElement('div'); + statusEl.className = 'codex-steer-status'; + bubble.appendChild(statusEl); + } + statusEl.dataset.status = normalized; + statusEl.textContent = message || codexAppSteerStatusLabel(normalized); + return true; + } + + function updateCodexAppSteerMessage(clientMessageId, status, message) { + const id = String(clientMessageId || '').trim(); + if (!id) return false; + const indexed = userMessageIndex.get(id); + const element = indexed?.element || messagesDiv.querySelector(`[data-message-id="${cssEscape(id)}"]`); + return setCodexAppSteerStatusElement(element, status, message); + } + + function scheduleTransientMessageRemoval(element, timeoutMs) { + const ttl = Number(timeoutMs); + if (!element || !Number.isFinite(ttl) || ttl <= 0) return; + window.setTimeout(() => { + if (!element || !element.isConnected) return; + element.classList.add('is-dismissing'); + window.setTimeout(() => { + if (!element || !element.isConnected) return; + element.remove(); + updateScrollbar(); + }, 220); + }, ttl); + } + function createMsgElement(role, content, attachments = [], meta = {}) { const div = document.createElement('div'); const isCrossConversation = role === 'user' && !!meta.crossConversation; @@ -3384,13 +3444,20 @@ } if (role === 'system') { + const tone = String(meta.tone || 'neutral').trim() || 'neutral'; const bubble = document.createElement('div'); bubble.className = 'msg-bubble'; + bubble.dataset.tone = tone; const text = document.createElement('span'); text.className = 'system-message-text'; text.textContent = content; bubble.appendChild(text); + const transient = !!meta.transient; + if (transient) { + div.classList.add('transient'); + } + const closeBtn = document.createElement('button'); closeBtn.type = 'button'; closeBtn.className = 'system-message-close'; @@ -3404,6 +3471,9 @@ }); bubble.appendChild(closeBtn); div.appendChild(bubble); + if (transient) { + scheduleTransientMessageRemoval(div, meta.autoDismissMs || 6000); + } return div; } @@ -3489,6 +3559,9 @@ hydrateAttachmentPreviews(bubble, attachments); div.appendChild(avatar); div.appendChild(bubble); + if (role === 'user' && meta.codexAppSteerStatus) { + setCodexAppSteerStatusElement(div, meta.codexAppSteerStatus, meta.codexAppSteerMessage); + } if (role === 'user') { registerUserMessage(resolvedMessageId, div, content); } @@ -4575,19 +4648,19 @@ overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); } - function appendSystemMessage(message) { + function appendSystemMessage(message, options = {}) { const welcome = messagesDiv.querySelector('.welcome-msg'); if (welcome) welcome.remove(); - messagesDiv.appendChild(createMsgElement('system', message)); + messagesDiv.appendChild(createMsgElement('system', message, [], options)); scrollToBottom(); } - function appendError(message) { - const div = document.createElement('div'); - div.className = 'msg system'; - div.innerHTML = `
⚠ ${escapeHtml(message)}
`; - messagesDiv.appendChild(div); - scrollToBottom(); + function appendError(message, options = {}) { + appendSystemMessage(`⚠ ${message}`, { + tone: 'danger', + transient: options.transient !== false, + autoDismissMs: options.autoDismissMs || 7000, + }); } function scrollToBottom() { @@ -5260,12 +5333,12 @@ return; } const messageId = createLocalId('user'); - const element = createMsgElement('user', text, [], { messageId }); + const element = createMsgElement('user', text, [], { messageId, codexAppSteerStatus: 'pending' }); messagesDiv.appendChild(element); registerUserMessage(messageId, element, text); updateUserOutlinePanel(); scrollToBottom(); - send({ type: 'message', text, sessionId: currentSessionId, mode: currentMode, agent: currentAgent }); + send({ type: 'message', text, sessionId: currentSessionId, mode: currentMode, agent: currentAgent, clientMessageId: messageId }); msgInput.value = ''; autoResize(); return; diff --git a/public/index.html b/public/index.html index 3edeaa1..5a1f83c 100644 --- a/public/index.html +++ b/public/index.html @@ -14,7 +14,7 @@ document.documentElement.dataset.dividerTime = dividerTime; })(); - + @@ -150,6 +150,6 @@ - + diff --git a/public/style.css b/public/style.css index 2b1bd91..a336bfd 100644 --- a/public/style.css +++ b/public/style.css @@ -1901,6 +1901,64 @@ body.session-loading-active { border-bottom-right-radius: 4px; padding-right: 42px; } +.msg.user.codex-steer-message .msg-bubble { + padding-bottom: 10px; +} +.msg.user.codex-steer-pending .msg-avatar, +.msg.user.codex-steer-inserted .msg-avatar, +.msg.user.codex-steer-failed .msg-avatar { + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + color: var(--text-muted); +} +.msg.user.codex-steer-pending .msg-bubble { + background: var(--bg-tertiary); + border: 1px dashed var(--border-color); + color: var(--text-secondary); +} +.msg.user.codex-steer-inserted .msg-bubble { + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + color: var(--text-secondary); + opacity: 0.94; +} +.msg.user.codex-steer-failed .msg-bubble { + background: rgba(220, 53, 69, 0.1); + border: 1px solid rgba(220, 53, 69, 0.32); + color: var(--danger); +} +.codex-steer-status { + display: flex; + align-items: center; + gap: 6px; + margin-top: 6px; + font-size: 11px; + font-weight: 700; + line-height: 1.2; + opacity: 0.88; +} +.codex-steer-status::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 999px; + background: currentColor; + opacity: 0.72; +} +.codex-steer-status[data-status='pending'] { + color: var(--text-muted); +} +.codex-steer-status[data-status='inserted'] { + color: var(--success); +} +.codex-steer-status[data-status='failed'] { + color: var(--danger); +} +.msg.user.codex-steer-pending .msg-copy-btn { + background: rgba(255, 255, 255, 0.08); + border-color: var(--border-color); + color: var(--text-muted); +} .msg-copy-btn { position: absolute; top: 8px; @@ -2082,6 +2140,23 @@ body.session-loading-active { text-align: center; white-space: pre-line; } +.msg.system.transient .msg-bubble { + border-style: solid; + box-shadow: 0 6px 18px rgba(27, 39, 51, 0.08); +} +.msg.system .msg-bubble[data-tone='danger'] { + border-color: rgba(220, 53, 69, 0.34); + color: var(--danger); +} +.msg.system .msg-bubble[data-tone='info'] { + border-color: rgba(58, 134, 255, 0.26); + color: var(--text-secondary); +} +.msg.system.is-dismissing { + opacity: 0; + transform: translateY(-4px); + transition: opacity 0.22s ease, transform 0.22s ease; +} .msg.system .system-message-text { display: block; padding-right: 26px; diff --git a/scripts/regression.js b/scripts/regression.js index 41320c2..f021c49 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -887,9 +887,37 @@ async function main() { ws.send(JSON.stringify({ type: 'message', text: 'slow codexapp prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' })); await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === codexAppSession.sessionId && s.isRunning)); await sleep(150); - ws.send(JSON.stringify({ type: 'message', text: 'runtime steer insert', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' })); + ws.send(JSON.stringify({ + type: 'message', + text: 'runtime steer insert', + sessionId: codexAppSession.sessionId, + mode: 'yolo', + agent: 'codexapp', + clientMessageId: 'regression-steer-message', + })); + const steerPending = await nextMessage(messages, ws, (msg) => + msg.type === 'codex_app_steer_status' && + msg.sessionId === codexAppSession.sessionId && + msg.clientMessageId === 'regression-steer-message' && + msg.status === 'pending' + ); + assert(/引导中/.test(steerPending.message || ''), 'Codex App steer should expose pending status'); const steerDelta = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /steer accepted: runtime steer insert/.test(msg.text || '')); assert(/runtime steer insert/.test(steerDelta.text || ''), 'Codex App running message should use turn/steer'); + const steerInserted = await nextMessage(messages, ws, (msg) => + msg.type === 'codex_app_steer_status' && + msg.sessionId === codexAppSession.sessionId && + msg.clientMessageId === 'regression-steer-message' && + msg.status === 'inserted' + ); + assert(/已插入/.test(steerInserted.message || ''), 'Codex App steer should expose inserted status'); + const steerSystemMessage = await nextMessage(messages, ws, (msg) => + msg.type === 'system_message' && + msg.sessionId === codexAppSession.sessionId && + /已引导对话: runtime steer insert/.test(msg.message || '') + ); + assert(steerSystemMessage.transient === true, 'Codex App steer marker should be transient'); + assert(/已引导对话: runtime steer insert/.test(steerSystemMessage.message || ''), 'Codex App steer should show guided conversation marker with preview'); await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId); storedCodexApp = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8')); assert(storedCodexApp.codexAppThreadId === codexAppThreadId, 'Codex App follow-up should resume the same app-server thread'); diff --git a/server.js b/server.js index b401565..168746e 100644 --- a/server.js +++ b/server.js @@ -4583,6 +4583,12 @@ function normalizeCodexAppUserInputAnswers(rawAnswers = {}) { return { answers }; } +function previewInlineText(text, maxLength = 36) { + const normalized = String(text || '').replace(/\s+/g, ' ').trim(); + if (!normalized) return '空内容'; + return normalized.length > maxLength ? `${normalized.slice(0, maxLength - 1)}...` : normalized; +} + function requestCodexAppUserInput(routed, params = {}) { if (!routed?.entry?.ws) { return Promise.resolve({ answers: {} }); @@ -5046,8 +5052,23 @@ function handleCodexAppSteerMessage(ws, msg, options = {}) { const sessionId = sanitizeId(msg?.sessionId || ''); const entry = activeCodexAppTurns.get(sessionId); if (!entry) return null; + const clientMessageId = String(msg?.clientMessageId || '').trim(); + + const sendSteerStatus = (status, message) => { + const targetWs = entry.ws || ws; + if (!targetWs) return; + const payload = { + type: 'codex_app_steer_status', + sessionId, + status, + message, + }; + if (clientMessageId) payload.clientMessageId = clientMessageId; + wsSend(targetWs, payload); + }; const fail = (code, message) => { + sendSteerStatus('failed', '插入失败'); wsSend(ws, { type: 'error', code, message }); return { ok: false, code, message }; }; @@ -5099,18 +5120,32 @@ function handleCodexAppSteerMessage(ws, msg, options = {}) { wsSend(ws, { type: 'session_message', sessionId, message: persistedUserMessage }); } + sendSteerStatus('pending', '引导中...'); const input = codexAppInputFromMessage(runtimeTextValue, []); const expectedTurnId = entry.turnId; codexAppClient.request('turn/steer', { threadId: entry.threadId, expectedTurnId, input, - clientUserMessageId: crypto.randomUUID(), - }, 60000).catch((err) => { + clientUserMessageId: clientMessageId || crypto.randomUUID(), + }, 60000).then(() => { + sendSteerStatus('inserted', '已插入'); + wsSend(entry.ws || ws, { + type: 'system_message', + sessionId, + tone: 'info', + transient: true, + autoDismissMs: 5000, + message: `已引导对话: ${previewInlineText(textValue)}`, + }); + }).catch((err) => { + sendSteerStatus('failed', '插入失败'); wsSend(entry.ws || ws, { type: 'error', sessionId, code: 'codexapp_steer_failed', + transient: true, + autoDismissMs: 7000, message: formatRuntimeError('codex', err?.message || err, { exitCode: null, signal: null }), }); });