diff --git a/static/style.css b/static/style.css
index a178d697..63cb9f2f 100644
--- a/static/style.css
+++ b/static/style.css
@@ -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 */
diff --git a/static/ui.js b/static/ui.js
index b804fc3e..35c69fb1 100644
--- a/static/ui.js
+++ b/static/ui.js
@@ -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=`
`;
+ const nextRowHtml=`${li('terminal',13)}Background wakeup
${processFootHtml}
`;
+ 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}${bodyHtml}
${footHtml}`;
if(row){
+ row.className='msg-row';
row.id=_userMessageDomId(rawIdx);
row.dataset.msgIdx=rawIdx;
row.dataset.sessionMsgIdx=_messageSessionIndexForRawIdx(rawIdx);
diff --git a/tests/test_process_wakeup_rendering.py b/tests/test_process_wakeup_rendering.py
new file mode 100644
index 00000000..d7df1bcf
--- /dev/null
+++ b/tests/test_process_wakeup_rendering.py
@@ -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 (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