fix:[WebUI] 修正 background wakeup 訊息渲染邊界
This commit is contained in:
1 parent
432f596cfb
commit
fa04044e7c
3 files changed
+223
-1
No files matched your search
@@ -2318,6 +2318,13 @@
|
||||
.provider-error-details{margin:12px 0 0;border:1px solid var(--border);border-radius:10px;background:var(--surface);overflow:hidden;}
|
||||
.provider-error-details>summary{cursor:pointer;color:var(--muted);font-size:12px;font-weight:600;padding:8px 12px;}
|
||||
.provider-error-details>pre{margin:0;border:0;border-top:1px solid var(--border);border-radius:0;max-height:220px;}
|
||||
.process-wakeup-row{padding:6px 0;}
|
||||
.process-wakeup-notice{margin-left:30px;max-width:680px;padding:8px 10px;border:1px solid var(--border);border-left:2px solid var(--accent-bg-strong);border-radius:9px;background:var(--surface-subtle,var(--hover-bg));color:var(--muted);}
|
||||
.process-wakeup-label{display:flex;align-items:center;gap:6px;margin-bottom:5px;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--accent-text);}
|
||||
.process-wakeup-label svg{width:13px;height:13px;flex:0 0 auto;}
|
||||
.process-wakeup-row .msg-body.process-wakeup-body{padding-left:0;max-width:none;color:var(--muted);}
|
||||
.process-wakeup-row .msg-body pre.process-wakeup-text{margin:0;padding:0;border:0;border-radius:0;background:transparent;color:var(--muted);font-family:var(--font-mono);font-size:12px;line-height:1.5;white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word;}
|
||||
.process-wakeup-row .msg-foot{padding-left:0;margin-top:5px;}
|
||||
/* Keep original theme background — prevent prism-tomorrow from overriding --code-bg */
|
||||
.msg-body pre[class*="language-"],.msg-body pre code[class*="language-"]{background:var(--code-bg) !important;}
|
||||
/* Fix #1463: Prism YAML grammar collapses newlines inside token spans — force pre */
|
||||
|
||||
+41
-1
@@ -526,6 +526,7 @@ const MESSAGE_VIRTUAL_THRESHOLD_ROWS=80;
|
||||
const MESSAGE_VIRTUAL_BUFFER_PX=900;
|
||||
const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHTS={
|
||||
user:120,
|
||||
process_wakeup:96,
|
||||
assistant:160,
|
||||
tool_call:400,
|
||||
default:140,
|
||||
@@ -616,7 +617,7 @@ function _cancelMessageVirtualizedRender(){
|
||||
}
|
||||
function _messageIsRenderable(m){
|
||||
if(!m||!m.role||m.role==='tool') return false;
|
||||
if(m._source === 'process_wakeup') return false;
|
||||
if(m._source === 'process_wakeup') return !!(msgContent(m)||m.attachments?.length);
|
||||
if(_isContextCompactionMessage(m)||_isPreservedCompressionTaskListMessage(m)) return false;
|
||||
if(_isRecoveryControlMessage(m)) return false;
|
||||
const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0;
|
||||
@@ -811,6 +812,7 @@ function _syncMessageVirtualHeightCache(visWithIdx){
|
||||
function _messageVirtualRoleForEntry(entry){
|
||||
const m=entry&&entry.m;
|
||||
if(!m) return 'default';
|
||||
if(m._source === 'process_wakeup') return 'process_wakeup';
|
||||
if(m.role==='user') return 'user';
|
||||
if(m.role==='assistant'){
|
||||
if((Array.isArray(m.tool_calls)&&m.tool_calls.length>0)||
|
||||
@@ -14419,11 +14421,13 @@ function renderMessages(options){
|
||||
}
|
||||
}
|
||||
}
|
||||
const isProcessWakeup=m&&m._source==='process_wakeup';
|
||||
const isUser=m.role==='user';
|
||||
if(!isUser&&_isMarkerOnlyAssistantCompressionMessage(m)){
|
||||
content='**Error:** No response received after context compression. Please retry.';
|
||||
}
|
||||
const displayContent=isUser?_stripAttachedFilesMarkerForDisplay(_stripWorkspaceDisplayPrefix(content)):content;
|
||||
const rowDisplayContent=isProcessWakeup?content:displayContent;
|
||||
if(!isUser&&_isAssistantEmptyPlaceholderContent(m, displayContent)){
|
||||
content='';
|
||||
}
|
||||
@@ -14493,6 +14497,41 @@ function renderMessages(options){
|
||||
continue;
|
||||
}
|
||||
|
||||
if(isProcessWakeup){
|
||||
currentAssistantTurn=null;
|
||||
let row=_msgNodeRecycleEnabled?_recycleStash.get(rawIdx):null;
|
||||
if(row&&(!row.classList.contains('msg-row')||row.classList.contains('assistant-turn'))) row=null;
|
||||
const processText=String(rowDisplayContent||'').trim();
|
||||
const processFootHtml=`<div class="msg-foot">${timeHtml}<span class="msg-actions">${copyBtn}</span></div>`;
|
||||
const nextRowHtml=`<div class="process-wakeup-notice"><div class="process-wakeup-label">${li('terminal',13)}<span>Background wakeup</span></div><div class="msg-body process-wakeup-body"><pre class="process-wakeup-text">${esc(processText)}</pre></div>${processFootHtml}</div>`;
|
||||
if(row){
|
||||
row.className='msg-row process-wakeup-row';
|
||||
row.id=_userMessageDomId(rawIdx);
|
||||
row.dataset.msgIdx=rawIdx;
|
||||
row.dataset.sessionMsgIdx=_messageSessionIndexForRawIdx(rawIdx);
|
||||
row.dataset.messageAnchorKey=_messageViewportAnchorKeyForMessage(m);
|
||||
row.dataset.role='process_wakeup';
|
||||
delete row.dataset.editing;
|
||||
if(row.dataset.rawText!==processText||row.innerHTML!==nextRowHtml){
|
||||
row.dataset.rawText=processText;
|
||||
row.innerHTML=nextRowHtml;
|
||||
}
|
||||
}else{
|
||||
row=document.createElement('div');
|
||||
row.className='msg-row process-wakeup-row';
|
||||
row.id=_userMessageDomId(rawIdx);
|
||||
row.dataset.msgIdx=rawIdx;
|
||||
row.dataset.sessionMsgIdx=_messageSessionIndexForRawIdx(rawIdx);
|
||||
row.dataset.messageAnchorKey=_messageViewportAnchorKeyForMessage(m);
|
||||
row.dataset.role='process_wakeup';
|
||||
row.dataset.rawText=processText;
|
||||
row.innerHTML=nextRowHtml;
|
||||
}
|
||||
inner.appendChild(row);
|
||||
userRows.set(rawIdx, row);
|
||||
continue;
|
||||
}
|
||||
|
||||
if(isUser){
|
||||
currentAssistantTurn=null;
|
||||
let row=_msgNodeRecycleEnabled?_recycleStash.get(rawIdx):null;
|
||||
@@ -14500,6 +14539,7 @@ function renderMessages(options){
|
||||
const newRawText=String(displayContent).trim();
|
||||
const nextRowHtml=`${filesHtml}<div class="msg-body">${bodyHtml}</div>${footHtml}`;
|
||||
if(row){
|
||||
row.className='msg-row';
|
||||
row.id=_userMessageDomId(rawIdx);
|
||||
row.dataset.msgIdx=rawIdx;
|
||||
row.dataset.sessionMsgIdx=_messageSessionIndexForRawIdx(rawIdx);
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Regression coverage for process-wakeup transcript rendering.
|
||||
|
||||
A background-process wakeup is stored as a synthetic user turn
|
||||
(`_source: "process_wakeup"`). It must be visible by default, but it must not
|
||||
look like a human-authored chat bubble. Keeping it in the visible message list
|
||||
also preserves the user-turn boundary between the assistant response that
|
||||
preceded the notification and the assistant response produced by the wakeup.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
UI_JS_PATH = ROOT / "static" / "ui.js"
|
||||
STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
|
||||
NODE = shutil.which("node")
|
||||
|
||||
pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
|
||||
|
||||
_DRIVER = r"""
|
||||
const fs = require('fs');
|
||||
const src = fs.readFileSync(process.argv[1], 'utf8');
|
||||
function extractFunc(name){
|
||||
const start = src.indexOf('function ' + name);
|
||||
if(start === -1) throw new Error(name + ' not found');
|
||||
const params = src.indexOf('(', start);
|
||||
let depth = 0, close = -1;
|
||||
for(let i=params; i<src.length; i++){
|
||||
if(src[i] === '(') depth++;
|
||||
else if(src[i] === ')'){
|
||||
depth--;
|
||||
if(depth === 0){ close = i; break; }
|
||||
}
|
||||
}
|
||||
const brace = src.indexOf('{', close);
|
||||
depth = 0;
|
||||
for(let i=brace; i<src.length; i++){
|
||||
if(src[i] === '{') depth++;
|
||||
else if(src[i] === '}'){
|
||||
depth--;
|
||||
if(depth === 0) return src.slice(start, i + 1);
|
||||
}
|
||||
}
|
||||
throw new Error(name + ' body did not close');
|
||||
}
|
||||
function msgContent(m){
|
||||
if(!m) return '';
|
||||
if(typeof m.content === 'string') return m.content;
|
||||
if(Array.isArray(m.content)) return m.content.map(p => (p && (p.text || p.content)) || '').join('\n');
|
||||
return String(m.content || '');
|
||||
}
|
||||
function _isContextCompactionMessage(){ return false; }
|
||||
function _isPreservedCompressionTaskListMessage(){ return false; }
|
||||
function _isRecoveryControlMessage(){ return false; }
|
||||
function _messageHasReasoningPayload(){ return false; }
|
||||
function _assistantMessageHasVisibleContent(m){ return !!String(msgContent(m)).trim(); }
|
||||
|
||||
global.window = {};
|
||||
global.S = {messages: []};
|
||||
let _visWithIdxCache = null;
|
||||
let _visWithIdxCacheLen = 0;
|
||||
let _visWithIdxCacheSrc = null;
|
||||
|
||||
const heightStart = src.indexOf('const MESSAGE_RENDER_WINDOW_DEFAULT');
|
||||
const heightEnd = src.indexOf('const MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS', heightStart);
|
||||
if(heightStart !== -1 && heightEnd !== -1) eval(src.slice(heightStart, heightEnd));
|
||||
if(src.indexOf('function _isProcessWakeupMessage') !== -1) eval(extractFunc('_isProcessWakeupMessage'));
|
||||
eval(extractFunc('_messageIsRenderable'));
|
||||
eval(extractFunc('_getVisibleMessagesWithIdx'));
|
||||
eval(extractFunc('_messageVirtualRoleForEntry'));
|
||||
|
||||
const wakeup = {
|
||||
role: 'user',
|
||||
content: '[IMPORTANT: Background process proc_123 completed (exit_code=0).\nCommand: sleep 1\nOutput:\ndone]',
|
||||
_source: 'process_wakeup',
|
||||
timestamp: 1783405253.72,
|
||||
};
|
||||
S.messages = [
|
||||
{role: 'assistant', content: 'previous assistant report', timestamp: 1783405252.05},
|
||||
wakeup,
|
||||
{role: 'assistant', content: 'assistant response to wakeup', timestamp: 1783405254.10},
|
||||
];
|
||||
|
||||
const visible = _getVisibleMessagesWithIdx();
|
||||
const turns = [];
|
||||
let current = [];
|
||||
for(const entry of visible){
|
||||
const source = entry.m._source || '';
|
||||
if(entry.m.role === 'user'){
|
||||
if(current.length) turns.push(current);
|
||||
turns.push(['user:' + source + ':' + String(entry.m.content).slice(0, 35)]);
|
||||
current = [];
|
||||
}else if(entry.m.role === 'assistant'){
|
||||
current.push('assistant:' + entry.m.content);
|
||||
}
|
||||
}
|
||||
if(current.length) turns.push(current);
|
||||
const virtualRole = _messageVirtualRoleForEntry({m: wakeup});
|
||||
const virtualHeight = typeof _messageVirtualDefaultHeightForRole === 'function'
|
||||
? _messageVirtualDefaultHeightForRole(virtualRole)
|
||||
: null;
|
||||
|
||||
process.stdout.write(JSON.stringify({
|
||||
visible: visible.map(e => ({rawIdx: e.rawIdx, role: e.m.role, source: e.m._source || '', text: String(e.m.content).slice(0, 32)})),
|
||||
turns,
|
||||
virtualRole,
|
||||
virtualHeight,
|
||||
}));
|
||||
"""
|
||||
|
||||
|
||||
def _run_driver():
|
||||
assert NODE is not None
|
||||
proc = subprocess.run(
|
||||
[NODE, "-e", _DRIVER, str(UI_JS_PATH)],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_process_wakeup_message_stays_visible_and_preserves_turn_boundary():
|
||||
result = _run_driver()
|
||||
|
||||
assert result["visible"] == [
|
||||
{"rawIdx": 0, "role": "assistant", "source": "", "text": "previous assistant report"},
|
||||
{"rawIdx": 1, "role": "user", "source": "process_wakeup", "text": "[IMPORTANT: Background process p"},
|
||||
{"rawIdx": 2, "role": "assistant", "source": "", "text": "assistant response to wakeup"},
|
||||
]
|
||||
assert result["turns"] == [
|
||||
["assistant:previous assistant report"],
|
||||
["user:process_wakeup:[IMPORTANT: Background process proc"],
|
||||
["assistant:assistant response to wakeup"],
|
||||
]
|
||||
|
||||
|
||||
def test_process_wakeup_has_its_own_virtual_height_role():
|
||||
result = _run_driver()
|
||||
|
||||
assert result["virtualRole"] == "process_wakeup"
|
||||
assert isinstance(result["virtualHeight"], int)
|
||||
assert 1 <= result["virtualHeight"] <= 120
|
||||
|
||||
|
||||
def test_process_wakeup_uses_compact_status_row_not_normal_user_bubble():
|
||||
ui = UI_JS_PATH.read_text(encoding="utf-8")
|
||||
marker = "const isProcessWakeup="
|
||||
marker_idx = ui.find(marker)
|
||||
assert marker_idx != -1, "render loop must classify process-wakeup messages"
|
||||
process_branch_idx = ui.find("if(isProcessWakeup)", marker_idx)
|
||||
user_branch_idx = ui.find("if(isUser)", marker_idx)
|
||||
|
||||
assert process_branch_idx != -1, "process-wakeup render branch missing"
|
||||
assert user_branch_idx != -1, "normal user render branch missing"
|
||||
assert process_branch_idx < user_branch_idx, (
|
||||
"process-wakeup rows must render through the compact status branch "
|
||||
"before the normal user-bubble branch"
|
||||
)
|
||||
process_branch = ui[process_branch_idx:user_branch_idx]
|
||||
assert "process-wakeup-row" in process_branch
|
||||
assert "process-wakeup-notice" in process_branch
|
||||
assert "data-role='process_wakeup'" in process_branch or "dataset.role='process_wakeup'" in process_branch
|
||||
|
||||
assert ".process-wakeup-row" in STYLE_CSS
|
||||
assert ".process-wakeup-notice" in STYLE_CSS
|
||||
assert ".process-wakeup-text" in STYLE_CSS
|
||||
Reference in new issue
Block a user