fix:[WebUI] 修正 background wakeup 訊息渲染邊界

This commit is contained in:
Isla-Liu authored and t committed 2026-07-09 03:21:44 +00:00
1 parent 432f596cfb
commit fa04044e7c
3 files changed
+223 -1

No files matched your search

+7
View File
@@ -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
View File
@@ -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);
+175
View File
@@ -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